From cb8ce5aae6dacf31314f8bd9b148588a7b8b1cc1 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 01:03:21 -0400 Subject: [PATCH 01/11] feat(application): implement TLS/SSL and DTLS record layer + handshake framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcapkit.protocols.application.ssl.SSL is the shared record-layer base over the part TLS and DTLS genuinely share (RFC 9846 §5.1 / RFC 9147 §4): ContentType/legacy-version/length and content-type dispatch. tls.py and dtls.py are the two concrete subclasses; DTLS alone carries the epoch + sequence number in its record header and the message_seq/fragment_offset/ fragment_length header each of its handshake messages adds (RFC 6347 §4.2.2), plus a cookie field in its ClientHello that TLS's does not carry (RFC 6347 §4.2.1 / RFC 9147 §5.3). - ClientHello/ServerHello decode to their version, random, session ID, cipher suite(s), compression method(s) and extension list (TYPE+LENGTH framed, extension bodies undecoded); every other handshake type is message-TYPE-level framing only. Alert and ChangeCipherSpec decode in full only when cleartext (see below); application_data, heartbeat, ack and return_routability_check always stay opaque bytes. tls12_cid (25, RFC 9146) is excluded from the wire allowlist entirely and falls back to Raw: its record format puts the connection ID before length, at an offset only the out-of-band negotiated CID length can resolve, which this dissector's stateless, per-packet schema read has no way to track. - SSL._validate_tls_record rejects a content type outside IANA's assigned 20-27, a legacy version outside TLS's/DTLS's range, or a declared length that runs past the buffer, so dispatch falls back to Raw instead of fabricating a record from unrelated bytes. The content-type test is an allowlist rather than "did the registry name this value": invalid(0) is named -- RFC 9846 §5.1 defines it so a record carrying it can be rejected -- and a name-based test accepted `00 03 03 00 04 deadbeef` on port 443 as a TLS record. - handshake, alert and change_cipher_spec records are decoded structurally only when cleartext -- the same discipline for all three, generalised in SSL._tls_content_is_cleartext. Past the handshake, TLS 1.2's Finished and any renegotiation handshake/alert/ChangeCipherSpec travel as ciphertext under the same content type, with nothing in the cleartext header to say so (an encrypted alert includes the close_notify that ends nearly every TLS 1.2 connection); TLS 1.3's post-handshake NewSessionTicket/KeyUpdate are not under content type 22 at all, since TLS 1.3's outer record type is always application_data once encryption starts. DTLS decodes all three only at epoch 0 (the epoch counts cipher changes, RFC 9147 §4.2); TLS has no such field, so handshake falls back to whether the 4-octet message headers tile the fragment exactly, and alert/change_cipher_spec to whether the fragment is exactly 2/1 octets -- an encrypted one can never land on either exactly, given MAC/tag overhead. Otherwise the octets are kept as-is rather than mis-decoded or dropped to Raw. - New const registries pcapkit.const.tls.{content_type,handshake_type}, following the generated-shape convention (EnumRegistry + IntEnum). Both name every value IANA assigns, ContentType through RFC 9853's return_routability_check(27) and HandshakeType through RFC 9147's connection-ID messages (9, 10), RFC 8879's compressed_certificate and RFC 8870's ekt_key. - TCP port 443 (https) and UDP port 5684 (coaps) are bound to TLS/DTLS; 5684 is not CoAP's only fixed DTLS-borne port, just the one bound here. - Out of scope, each documented where it bites: reassembling a DTLS handshake message fragmented across records (#2440), reassembling a TLS record split across TCP segments (decodes as Raw until the record arrives whole), and CID-aware offset parsing for tls12_cid records. Every message shape implemented has a hand-crafted byte fixture proven byte-exact both ways (parse-then-rebuild and make-then-parse), including a genuine DTLS ClientHello with a cookie, both encrypted-handshake cases, and encrypted-alert cases for both protocols (a 26-octet AES-GCM-shaped close_notify, a 48-octet CBC alert, and a DTLS epoch-1 alert). tls.py and dtls.py are at 100% statement and branch coverage; ssl.py is at 99%, the one miss being an unreachable defensive branch. Build/test: mypy reports nothing on any changed file and isort is clean; pylint shows only the pre-existing schema-DSL and duplicate-code classes. tests/protocols, tests/project and tests/const all pass. Refs #2437 --- docs/source/contributing/pep.rst | 95 +- .../pcapkit/protocols/application/dtls.rst | 83 ++ .../pcapkit/protocols/application/index.rst | 5 +- .../pcapkit/protocols/application/ngap.rst | 7 +- .../pcapkit/protocols/application/ssl.rst | 87 ++ .../pcapkit/protocols/application/tls.rst | 102 +++ examples/generators/dispatch.py | 37 +- pcapkit/const/tls/__init__.py | 29 + pcapkit/const/tls/content_type.py | 94 ++ pcapkit/const/tls/handshake_type.py | 146 +++ .../application/NotImplemented/tls.py | 0 pcapkit/protocols/application/dtls.py | 251 ++++++ pcapkit/protocols/application/ngap.py | 7 +- pcapkit/protocols/application/ssl.py | 836 ++++++++++++++++++ pcapkit/protocols/application/tls.py | 239 +++++ pcapkit/protocols/data/__init__.py | 8 + .../protocols/data/application/__init__.py | 21 + pcapkit/protocols/data/application/dtls.py | 91 ++ pcapkit/protocols/data/application/tls.py | 160 ++++ pcapkit/protocols/schema/__init__.py | 2 + .../protocols/schema/application/__init__.py | 12 + pcapkit/protocols/schema/application/dtls.py | 52 ++ pcapkit/protocols/schema/application/tls.py | 51 ++ pcapkit/protocols/transport/sctp.py | 20 +- pcapkit/protocols/transport/tcp.py | 6 +- pcapkit/protocols/transport/udp.py | 10 + tests/const/test_const_enum_builtin_parity.py | 6 +- tests/const/test_const_enum_get.py | 4 +- tests/const/test_const_enum_lookup.py | 2 +- tests/const/test_const_registry_protocol.py | 4 +- .../test_rfc_constants_one_home_1739_unit.py | 6 + .../application/test_ssl_tls_dtls_unit.py | 599 +++++++++++++ .../test_tls_dtls_roundtrip_unit.py | 324 +++++++ .../protocols/test_dispatch_bindings_unit.py | 6 +- .../protocols/test_dispatch_registry_unit.py | 12 +- tests/protocols/transport/test_sctp_unit.py | 7 +- 36 files changed, 3342 insertions(+), 79 deletions(-) create mode 100644 docs/source/pcapkit/protocols/application/dtls.rst create mode 100644 docs/source/pcapkit/protocols/application/ssl.rst create mode 100644 docs/source/pcapkit/protocols/application/tls.rst create mode 100644 pcapkit/const/tls/__init__.py create mode 100644 pcapkit/const/tls/content_type.py create mode 100644 pcapkit/const/tls/handshake_type.py delete mode 100644 pcapkit/protocols/application/NotImplemented/tls.py create mode 100644 pcapkit/protocols/application/dtls.py create mode 100644 pcapkit/protocols/application/ssl.py create mode 100644 pcapkit/protocols/application/tls.py create mode 100644 pcapkit/protocols/data/application/dtls.py create mode 100644 pcapkit/protocols/data/application/tls.py create mode 100644 pcapkit/protocols/schema/application/dtls.py create mode 100644 pcapkit/protocols/schema/application/tls.py create mode 100644 tests/protocols/application/test_ssl_tls_dtls_unit.py create mode 100644 tests/protocols/application/test_tls_dtls_roundtrip_unit.py diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 0decc56a15..97a2d48a62 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -43,14 +43,19 @@ 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 the third such +pair, TLS/SSL with DTLS: :class:`~pcapkit.protocols.application.ssl.SSL` is +the shared record-layer base, with +:class:`~pcapkit.protocols.application.tls.TLS` and +:class:`~pcapkit.protocols.application.dtls.DTLS` as its two concrete +implementations -- 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 +67,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 +227,43 @@ 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` (TLS 1.2, :rfc:`5246`; TLS +1.3, :rfc:`9846`) and :class:`~pcapkit.protocols.application.dtls.DTLS` +(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 was never needed to make the record structure legible, +in the same way :class:`ESP ` parses +without keys. + +**Not yet done: reassembling a handshake message DTLS has fragmented across +several records** (:rfc:`6347#section-4.2.3`). That needs a +:class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` subclass keyed +on the UDP 5-tuple plus epoch, which is substantial enough that it was left +for a follow-up rather than landing alongside the dissector +(:issue:`2440`). Until then, a fragmented message's body is kept as that one +fragment's raw octets -- see +:attr:`DTLSHandshake.is_fragmented ` +-- rather than attempted as a decode only a slice of the message could ever +back correctly. An unfragmented message, the common case, 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** +unblocked by this: :attr:`SCTP.__proto__ ` +still maps PPID 66 straight to :class:`~pcapkit.protocols.application.ngap.NGAP` +rather than through :class:`~pcapkit.protocols.application.dtls.DTLS`, so those +bytes still degrade straight to :class:`~pcapkit.protocols.misc.raw.Raw`. Rewiring +that dispatch, and deciding what an undecrypted DTLS-wrapped NGAP PDU should +report, is left to its own follow-up. Registered, but Not Dissected ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -307,10 +320,15 @@ 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, and TCP 443 (IANA ``https``) + is bound to :class:`~pcapkit.protocols.application.tls.TLS`, now that it + parses the record layer de-facto 443 traffic carries; UDP 5684 (IANA + ``coaps``) is bound to :class:`~pcapkit.protocols.application.dtls.DTLS` + likewise. **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 +1096,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.** See the `DTLS`_ section above for +what landed (the shared :class:`~pcapkit.protocols.application.ssl.SSL` base, +both concrete protocols, and handshake message framing) and what is left +(DTLS handshake-fragment reassembly, tracked as its own follow-up). + **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/pcapkit/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst new file mode 100644 index 0000000000..e97bcc6a8c --- /dev/null +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -0,0 +1,83 @@ +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.2 +(:rfc:`6347`) and DTLS 1.3 (: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. + +Known Limitation +----------------- + +**A handshake message split across several DTLS records is not reassembled.** +DTLS fragments a handshake message when it does not fit in one datagram +(:rfc:`6347#section-4.2.3`); reassembling that would need +:class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` keyed on the +UDP 5-tuple plus epoch, which is substantial enough that it did not fit +cleanly alongside this dissector in the same PR (:issue:`2440`). A fragment's +:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.is_fragmented` +says whether this is the case; such a message's +:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` is kept +as that one fragment's raw octets rather than attempted as a (necessarily +wrong, since only a slice of it is present) decode. An *unfragmented* message +-- ``fragment_offset == 0`` and ``fragment_length == length`` -- decodes +exactly as :class:`~pcapkit.protocols.application.tls.TLS` would, which is +the common case: a ``ClientHello`` or ``ServerHello`` that fits one datagram +never fragments. + +.. versionadded:: 1.6.0 + + :issue:`2437`. + +.. raw:: html + +
+ +.. autoclass:: pcapkit.protocols.application.dtls.DTLS + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: length + + .. 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..1f4fe326f4 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__ + ` still maps PPID 66 + straight 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..43ab59ddfe --- /dev/null +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -0,0 +1,87 @@ +SSL - Secure Sockets Layer +=============================== + +.. module:: pcapkit.protocols.application.ssl + +:mod:`pcapkit.protocols.application.ssl` contains +:class:`~pcapkit.protocols.application.ssl.SSL` only, +which is a base class for Transport Layer Security (TLS) protocol +family [*]_, e.g. +:class:`~pcapkit.protocols.application.tls.TLS` and +:class:`~pcapkit.protocols.application.dtls.DTLS`. + +The record-layer header -- a :class:`~pcapkit.const.tls.content_type.ContentType`, +a legacy record version, and a length -- is byte-identical in shape between +TLS (:rfc:`9846#section-5.1`) and DTLS (:rfc:`9147#section-4`); DTLS's own +divergence (an explicit epoch and sequence number in the record header, and +handshake-message fragmentation that a reliable TLS stream never needs) stays +entirely in :class:`~pcapkit.protocols.application.dtls.DTLS`. This base class +carries the logic the two genuinely share -- content-type dispatch, handshake- +message decoding, and the record-level validation that keeps dispatch from +accepting bytes that cannot be a genuine record. + +.. versionadded:: 1.6.0 + + :issue:`2437`. + +.. 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..c6ebf8250f --- /dev/null +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -0,0 +1,102 @@ +TLS/SSL - Transport Layer Security +======================================== + +.. module:: pcapkit.protocols.application.tls + +:mod:`pcapkit.protocols.application.tls` contains +:class:`~pcapkit.protocols.application.tls.TLS` only, which implements an +extractor for Transport Layer Security (TLS) [*]_ -- TLS 1.2 (:rfc:`5246`) and +TLS 1.3 (:rfc:`9846`, which obsoletes :rfc:`8446`) -- at the record layer, plus +handshake message TYPE-level framing. + +Decoded Fields +-------------- + +* The record header -- content type, legacy version, length -- in full, + regardless of encryption: this part is always in the clear. +* For a **cleartext** ``handshake``, ``alert``, or ``change_cipher_spec`` + record -- i.e. one that has not yet started carrying ciphertext, decided + per-record by + :meth:`SSL._tls_content_is_cleartext `: + + * ``ClientHello``/``ServerHello`` down to their cipher suite(s), + compression method(s), and extension list, TYPE+LENGTH framed (extension + bodies are not decoded). + * Every other handshake message (``Certificate``, ``ServerKeyExchange``, + ``ClientKeyExchange``, etc.) at the message-type/length level only: its + body is kept undecoded. ``Finished`` is always ciphertext -- there is no + cleartext ``Finished`` -- so it is never decoded even at this level and + always stays opaque bytes. + * ``Alert`` and ``ChangeCipherSpec`` records in full. + +* An **encrypted** ``handshake``, ``alert``, or ``change_cipher_spec`` + record -- which is the common case for TLS 1.2 traffic once the handshake + completes -- plus ``application_data`` and every other content type, as + opaque :obj:`bytes`. + +The record header is visible on the wire regardless of encryption, but the +record *content* is not: once the handshake has derived keys, a +``handshake``/``alert``/``change_cipher_spec`` record may carry ciphertext +indistinguishable at the header level from its cleartext counterpart, and +only the content stays undecoded in that case -- see +:meth:`SSL._tls_content_is_cleartext ` +for how each case is told apart. Decrypting ``application_data`` is out of +scope. + +Known Limitation +----------------- + +**A record split across two TCP segments is not reassembled**, and so decodes +as :class:`~pcapkit.protocols.misc.raw.Raw` rather than as the record it is the +start of. A record may be up to 16 KiB against a typical 1460-octet MSS, so +this is routine rather than rare. See +:meth:`SSL._validate_tls_record ` +for why rejecting such a fragment is the honest answer at this layer. + +.. versionadded:: 1.6.0 + + :issue:`2437`. + +.. raw:: html + +
+ +.. autoclass:: pcapkit.protocols.application.tls.TLS + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: length + + .. automethod:: read + .. automethod:: make + + .. automethod:: __length_hint__ + + .. automethod:: _make_data + +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/examples/generators/dispatch.py b/examples/generators/dispatch.py index ac1d0af029..d5b1a06eb6 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__ ` +still maps it straight 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 still maps + # it straight 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/const/tls/__init__.py b/pcapkit/const/tls/__init__.py new file mode 100644 index 0000000000..91861f5991 --- /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 all constant enumerations of +:class:`~pcapkit.protocols.application.ssl.SSL` implementations -- i.e. +:class:`~pcapkit.protocols.application.tls.TLS` 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..8f93db97d8 --- /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` for DTLS -- +is identical in shape between the two, and this is the type it dispatches on. + +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/application/NotImplemented/tls.py b/pcapkit/protocols/application/NotImplemented/tls.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py new file mode 100644 index 0000000000..c2acbec825 --- /dev/null +++ b/pcapkit/protocols/application/dtls.py @@ -0,0 +1,251 @@ +# -*- 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 Datagram Transport Layer Security (DTLS) [*]_ -- DTLS 1.2 +(:rfc:`6347`) and DTLS 1.3 (: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. + +Known Limitation +----------------- + +**A handshake message split across several DTLS records is not reassembled.** +DTLS fragments a handshake message when it does not fit in one datagram +(:rfc:`6347#section-4.2.3`); reassembling that would need +:class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` keyed on the +UDP 5-tuple plus epoch, which is substantial enough that it did not fit +cleanly alongside this dissector in the same PR (:issue:`2437`; see the +follow-up issue it links for exactly what is missing). A fragment's +:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.is_fragmented` +says whether this is the case; such a message's +:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` is kept +as that one fragment's raw octets rather than attempted as a (necessarily +wrong, since only a slice of it is present) decode. An *unfragmented* message +-- ``fragment_offset == 0`` and ``fragment_length == length`` -- decodes +exactly as :class:`~pcapkit.protocols.application.tls.TLS` would, which is +the common case: a ``ClientHello`` or ``ServerHello`` that fits one datagram +never fragments. + +.. [*] https://en.wikipedia.org/wiki/Datagram_Transport_Layer_Security + +.. 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 +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 + 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__ = ['DTLS'] + + +class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): + """This class implements Datagram Transport Layer Security. + + Note: + The subpackage does not track the dispatch tier: DTLS is dispatched + from :attr:`UDP.__proto__ ` + at port 5684, the ``coaps`` (CoAP over DTLS, :rfc:`7252`) service name + IANA assigns there. It is not the only such port -- IANA registers + DTLS-borne services at several, among them 853 (``domain-s``), 4740 + (``ipfixs``), 5349 (``stuns``/``turns``), 6514 (``syslog-tls``) and + 10161/10162 (``snmpdtls``) -- so binding 5684 alone is a choice about + what is common on the wire rather than an exhaustive reading of the + registry, and plenty of deployments negotiate DTLS on a dynamic port + no registry can name. Binding another is one + :func:`pcapkit.foundation.registry.protocols.register_udp` call. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Datagram Transport Layer Security"]': + """Name of current protocol.""" + return 'Datagram Transport Layer Security' + + @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. + ########################################################################## + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': # pylint: disable=unused-argument + """Read Datagram Transport Layer Security (DTLS). + + Structure of DTLS record layer, the ``DTLSPlaintext`` form + [:rfc:`9147#section-4`] -- the full header this module reads; + ``DTLSCiphertext``, defined in that same section, is instead the + on-the-wire *compact* header an encrypted record may use once epoch 0 + has passed, which this module does not decode: + + .. code-block:: text + + struct { + ContentType type; + ProtocolVersion legacy_record_version; + uint16 epoch = 0; + uint48 sequence_number; + uint16 length; + opaque fragment[DTLSPlaintext.length]; + } DTLSPlaintext; + + 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. + + Notes: + A ``handshake``, ``alert``, or ``change_cipher_spec`` record is + decoded structurally only at epoch 0, the cleartext initial + handshake; at any later epoch the same content types carry + ciphertext, whose octets are kept as-is -- an encrypted + ``alert`` (including the ``close_notify`` that ends nearly every + TLS 1.2 connection) is the same shape of problem as an encrypted + ``handshake``, just one epoch later than the initial flight. See + :meth:`SSL._tls_content_is_cleartext + `. + + """ + schema = self.__header__ + available = length if length is not None else len(self) + self._validate_tls_record(schema, available=available, dtls=True) + + 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) + + 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. + + 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), + } diff --git a/pcapkit/protocols/application/ngap.py b/pcapkit/protocols/application/ngap.py index 356a0ecd3a..33e1f21dbe 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__ + ` still maps PPID 66 + straight 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..28bff9e8da --- /dev/null +++ b/pcapkit/protocols/application/ssl.py @@ -0,0 +1,836 @@ +# -*- 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 a base class for Transport Layer Security (TLS) protocol +family [*]_, e.g. +:class:`~pcapkit.protocols.application.tls.TLS` and +:class:`~pcapkit.protocols.application.dtls.DTLS`. + +The record-layer header -- a :class:`~pcapkit.const.tls.content_type.ContentType`, +a legacy record version, and a length -- is byte-identical in shape between +TLS (:rfc:`9846#section-5.1`) and DTLS (:rfc:`9147#section-4`); DTLS's own +divergence (an explicit epoch and sequence number in the record header, and +handshake-message fragmentation that a reliable TLS stream never needs) stays +entirely in :class:`~pcapkit.protocols.application.dtls.DTLS`. This base class +carries the logic the two genuinely share -- content-type dispatch and +handshake-message decoding -- parameterised by a ``dtls`` flag where DTLS's +extra fragment header has to be read or written; the record header fields +themselves are declared separately in each subclass's own schema +(:class:`~pcapkit.protocols.schema.application.tls.TLS` and +:class:`~pcapkit.protocols.schema.application.dtls.DTLS`), since DTLS inserts +its epoch and sequence number *between* the version and the length rather than +appending them. + +.. [*] 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, i.e. every value IANA's ``TLS +#: ContentType`` registry assigns. Written out rather than taken as "every +#: member :class:`~pcapkit.const.tls.content_type.ContentType` names" for two +#: reasons: :attr:`~pcapkit.const.tls.content_type.ContentType.invalid` is a +#: named member that is *not* a wire value -- :rfc:`9846#section-5.1` defines +#: ``invalid(0)`` as a sentinel to be rejected, and IANA keeps 0 inside its +#: Unassigned 0-19 band -- and iterating the registry would also be a moving +#: target, since a lookup of an unassigned value mints a member rather than +#: raising. An allowlist is what makes this a *decision* about the wire rather +#: than a restatement of whatever the registry happens to hold. +#: +#: :attr:`~pcapkit.const.tls.content_type.ContentType.tls12_cid` (25, +#: :rfc:`9146`) is deliberately left out. Every other type here keeps +#: ``length`` at the fixed offset :class:`~pcapkit.protocols.schema.application.tls.TLS`/ +#: :class:`~pcapkit.protocols.schema.application.dtls.DTLS` declare it at, but +#: a Connection ID record inserts the CID *before* ``length`` +#: (:rfc:`9146#section-4`), and the CID's own length is negotiated out of band +#: during the handshake -- it is not on the wire of the record itself. Reading +#: ``length`` at the fixed offset for a type-25 record decodes the tail of the +#: CID as the length field, fabricating a fragment that was never on the wire +#: (confirmed: an 8-octet CID made an 8-octet fragment read back as +#: ``length=17``). Parsing it correctly needs this dissector to track the +#: negotiated CID length per connection, which the current stateless, +#: per-packet schema read has nowhere to keep; until a follow-up adds that, +#: type 25 is excluded here so it falls back to +#: :class:`~pcapkit.protocols.misc.raw.Raw` instead of being decoded wrong. +_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, +}) + + +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 never raises on a short buffer -- ``b'ab'[0:4]`` is ``b'ab'`` + -- so a truncated handshake message would otherwise parse a few octets + short rather than fail. This is what makes each caller's spec-shaped slice + actually bounds-checked. + + 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. + + A ``handshake`` record's fragment is cleartext only until the handshake + derives keys; after that a record still typed ``handshake`` (a ``Finished``, + a post-handshake ``NewSessionTicket`` or ``KeyUpdate``) carries ciphertext, + and nothing in the cleartext header says which it is. What does distinguish + them is self-consistency: a cleartext fragment is a whole number of + messages, each a 4-octet ``msg_type``/``length`` header followed by exactly + ``length`` octets, so walking those headers lands on the fragment's last + octet exactly. Ciphertext is pseudorandom and overwhelmingly does not. + + Args: + fragment: Record fragment, i.e. the candidate handshake messages. + + Returns: + Whether the walk lands exactly on the end of ``fragment``. + + Notes: + This is a heuristic, and an imperfect one in both directions. A + ciphertext fragment whose octets happen to tile is still taken for + cleartext -- telling the two apart needs the session keys, which a + passive dissector does not have. And a genuine cleartext handshake + message that :rfc:`9846#section-5.1` has split across more than one + TLS record does *not* tile within a single record either, so this + walk fails it the same way it fails ciphertext; the "always tiles" + case holds only for a message that fits in one record, which is the + common one. Both misses land on the same, conservative side, though: + this method is consulted only to decide whether to decode, and + keeping either case's octets opaque loses nothing, while mis-decoding + ciphertext fabricates 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 + """Base class for the TLS protocol family. + + - Transport Layer Security (:class:`~pcapkit.protocols.application.tls.TLS`) [:rfc:`5246`, :rfc:`9846`] + - Datagram TLS (:class:`~pcapkit.protocols.application.dtls.DTLS`) [: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 TCP port 443 (or UDP + port 5684), and once a connection is past its handshake most of those + are mid-stream ciphertext continuations with no record header in them + at all -- not an edge case, the common case. Without this check, any + content-type octet and legacy version decode as *some* record: + :class:`~pcapkit.corekit.enum.EnumRegistry`'s ``_missing_`` mints an + ``Unassigned`` pseudo-member for a value no :class:`~pcapkit.const.tls.content_type.ContentType` + names rather than refusing it, and a declared :attr:`~pcapkit.protocols.schema.application.tls.TLS.length` + longer than what is actually left is silently zero-padded out to that + length by :meth:`FieldBase.unpack `'s + truncated-capture tolerance -- fabricating record content from octets + that were never on the wire, rather than signalling that this was not + a record to begin with. + + 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 two TCP segments is rejected**, since its + first segment carries a header whose declared ``length`` genuinely + does run past the end of that segment. A TLS record may be up to + 16 KiB (:rfc:`9846#section-5.1`) against a typical 1460-octet MSS, + so this is routine rather than rare -- a post-quantum + ``ClientHello`` alone exceeds one segment. Accepting it would need + the record reassembled across the stream first, which is + :class:`~pcapkit.foundation.reassembly.tcp.TCP`'s job and not + something this layer can see; until a record arrives whole, + degrading to :class:`~pcapkit.protocols.misc.raw.Raw` is the honest + answer, because the fragment this layer holds is not a record. + + """ + # NOTE: ``schema.content_type`` is the registry member when this + # schema came from a genuine parse (``EnumField.post_process`` + # resolves it there), but a plain :obj:`int` when it came from + # ``make()`` instead -- :meth:`Protocol._make_index` with + # ``pack=False`` returns the bare index despite its ``cast`` to the + # enum type, which is a typing fiction rather than a runtime one. So + # it is resolved through the registry explicitly here rather than + # read off the value directly, to cover both. + # + # NOTE: checked against ``_WIRE_CONTENT_TYPES`` rather than by asking + # whether the registry named the value: ``invalid(0)`` *is* named, and + # is still not a content type any record carries, so a name-based test + # accepted ``00 03 03 ...`` as a TLS record and decoded its fragment. + 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_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. + + Three content types -- ``handshake``, ``alert``, and + ``change_cipher_spec`` -- carry a record format that is genuine only + before keys are derived; once they are, a record still typed the same + way carries ciphertext, with nothing in the cleartext record header to + say which it is. Decoding the ciphertext case structurally either + fabricates content out of pseudorandom octets or fails and drops the + whole record to :class:`~pcapkit.protocols.misc.raw.Raw`, losing the + record header that *was* in the clear -- the bug this method exists to + avoid for all three types rather than just ``handshake``, where it was + first caught. + + The two protocols answer this differently because DTLS gives a direct + answer and TLS does not: + + * **DTLS** states it in the record header, for every content type: + the epoch counts cipher changes (:rfc:`9147#section-4.2`), so epoch + 0 is exactly the cleartext initial handshake and any later epoch is + encrypted. + * **TLS** has no such field, so each type falls back to its own + self-consistency check instead: + + * ``handshake`` -- :func:`_tls_handshake_tiles` asks whether the + fragment is a whole number of messages. + * ``alert`` -- a genuine cleartext alert is always exactly 2 octets + (``level`` + ``description``); an encrypted one can never land on + exactly 2, because of the MAC/tag overhead encryption adds. + * ``change_cipher_spec`` -- the same reasoning, at exactly 1 octet. + + Any other content type is reported as *not* cleartext -- a miss lands + on the conservative side, same as :func:`_tls_handshake_tiles` -- even + though callers today only consult this for the three above, so that a + future content type ever routed through this method without adding a + branch for it fails closed instead of silently reintroducing the bug + this method exists to avoid. + + 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: + None of ``handshake``, ``alert``, or ``change_cipher_spec`` are + necessarily *cleartext* just because of their content type. + TLS 1.2's ``Finished`` and any renegotiation handshake travel as + ciphertext under ``handshake`` once keys are derived, with no + flag in the cleartext header to say so -- and TLS 1.3's + post-handshake messages (``NewSessionTicket``, ``KeyUpdate``) are + not under this type at all, since TLS 1.3's outer record type is + always ``application_data`` once encryption starts, and + ``KeyUpdate`` has no TLS 1.2 equivalent to begin with. An + encrypted TLS 1.2 ``alert`` -- including the ``close_notify`` + that ends nearly every connection -- or a ``change_cipher_spec`` + sent mid-renegotiation is the same shape of problem under those + two types. Every one of the three is kept as opaque :obj:`bytes` + rather than decoded when it is not cleartext, which is what + :meth:`_tls_content_is_cleartext` decides. + + """ + 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 TLS record may coalesce several handshake messages (e.g. + ``Certificate``, ``ServerKeyExchange`` and ``ServerHelloDone`` in one + record), so this reads however many fit in ``fragment`` rather than + exactly one. + + 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: + A ``ClientHello``/``ServerHello`` body is decoded into + :class:`~pcapkit.protocols.data.application.tls.ClientHello`/ + :class:`~pcapkit.protocols.data.application.tls.ServerHello` + only when the whole message is present -- always true for TLS, + and true for DTLS only when the message is not fragmented across + several records. A DTLS message that is split across records is + not reassembled in this version (:issue:`2440`), so its body is + kept as the single fragment's raw octets regardless of + ``msg_type``. Every other ``msg_type`` is message-TYPE-level + framing only: its body is kept undecoded, as :issue:`2437` scopes + this PR to. + + """ + 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 by the ``else`` branch + # below when ``dtls`` is false, so there is no "not yet known" + # state for a type to carry, only a value :class:`DTLSHandshake` + # declares 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 own ``length`` is trusted only when it + # cannot be recovered any other way -- a fragmented DTLS message, + # where this fragment's body is a strict subset of the total. + # Otherwise it is recomputed from ``body_bytes``, so a message + # built by hand need not get a redundant length right, and a + # parse-then-rebuild stays exact either way (a read message's + # ``length`` already equals its body's, there or in DTLS's + # unfragmented case). + 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's ``ClientHello`` is identical except for one insertion: a + length-prefixed ``cookie`` directly after ``legacy_session_id`` and + before ``cipher_suites`` [:rfc:`6347#section-4.2.1`; + :rfc:`9147#section-5.3`'s ``legacy_cookie`` carries it forward + unchanged], which TLS's ``ClientHello`` does not carry at all: + + .. 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`), so an + # odd length never has an RFC-compliant decoding -- and without this + # check, the last, incomplete pair would quietly decode as one lone + # octet rather than fail, and :meth:`_make_tls_client_hello` would then + # re-pad it to 2 octets on rebuild, losing byte-exactness silently + # rather than refusing to parse. + if cipher_suites_len % 2 != 0: + 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 own body is not decoded (:issue:`2437`): it is kept + as :attr:`~pcapkit.protocols.data.application.tls.Extension.data`, + undecoded, which is what this PR scopes extension support to. + + 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..6d833fbd49 --- /dev/null +++ b/pcapkit/protocols/application/tls.py @@ -0,0 +1,239 @@ +# -*- 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` only, which implements an +extractor for Transport Layer Security (TLS) [*]_ -- TLS 1.2 (:rfc:`5246`) and +TLS 1.3 (:rfc:`9846`, which obsoletes :rfc:`8446`) -- at the record layer, plus +handshake message TYPE-level framing. + +Decoded Fields +-------------- + +* The record header -- content type, legacy version, length -- in full, + regardless of encryption: this part is always in the clear. +* For a **cleartext** ``handshake``, ``alert``, or ``change_cipher_spec`` + record -- i.e. one that has not yet started carrying ciphertext, decided + per-record by + :meth:`SSL._tls_content_is_cleartext `: + + * ``ClientHello``/``ServerHello`` down to their cipher suite(s), + compression method(s), and extension list, TYPE+LENGTH framed (extension + bodies are not decoded). + * Every other handshake message (``Certificate``, ``ServerKeyExchange``, + ``ClientKeyExchange``, etc.) at the message-type/length level only: its + body is kept undecoded. ``Finished`` is always ciphertext -- there is no + cleartext ``Finished`` -- so it is never decoded even at this level and + always stays opaque bytes. + * ``Alert`` and ``ChangeCipherSpec`` records in full. + +* An **encrypted** ``handshake``, ``alert``, or ``change_cipher_spec`` + record -- which is the common case for TLS 1.2 traffic once the handshake + completes -- plus ``application_data`` and every other content type, as + opaque :obj:`bytes`. + +The record header is visible on the wire regardless of encryption, but the +record *content* is not: once the handshake has derived keys, a +``handshake``/``alert``/``change_cipher_spec`` record may carry ciphertext +indistinguishable at the header level from its cleartext counterpart, and +only the content stays undecoded in that case -- see +:meth:`SSL._tls_content_is_cleartext ` +for how each case is told apart. Decrypting ``application_data`` is out of +scope. + +Known Limitation +----------------- + +**A record split across two TCP segments is not reassembled**, and so decodes +as :class:`~pcapkit.protocols.misc.raw.Raw` rather than as the record it is the +start of. A record may be up to 16 KiB against a typical 1460-octet MSS, so +this is routine rather than rare. See +:meth:`SSL._validate_tls_record ` +for why rejecting such a fragment is the honest answer at this layer. + +.. [*] https://en.wikipedia.org/wiki/Transport_Layer_Security + +.. 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 +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'] + + +class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): + """This class implements Transport Layer Security. + + 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, now that + this module parses the record layer it carries. See + :func:`pcapkit.foundation.registry.protocols.register_tcp`. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Transport Layer Security"]': + """Name of current protocol.""" + return 'Transport Layer Security' + + @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. + ########################################################################## + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': # pylint: disable=unused-argument + """Read Transport Layer Security (TLS). + + 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; + + 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) + + 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) + + 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. + + 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), + } 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..ecddbc58fe --- /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 in this version (: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..74c50f8260 --- /dev/null +++ b/pcapkit/protocols/schema/application/tls.py @@ -0,0 +1,51 @@ +# -*- 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 is byte-identical in shape to :class:`DTLS + `'s, minus the epoch and + sequence number DTLS inserts before ``length`` (:rfc:`9846#section-5.1`). + :attr:`fragment` is kept as the raw record fragment; everything it may + hold -- a list of handshake messages, an alert, change-cipher-spec, or + opaque application data -- is decoded in + :meth:`TLS.read ` by the + shared helpers on + :class:`~pcapkit.protocols.application.ssl.SSL`, rather than here, so that + :class:`~pcapkit.protocols.schema.application.dtls.DTLS` can reuse the same + decoding logic over its own (fragment-headered) handshake messages. + + """ + + #: 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..77eb5b8a8c 100644 --- a/pcapkit/protocols/transport/sctp.py +++ b/pcapkit/protocols/transport/sctp.py @@ -333,10 +333,15 @@ 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 still 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` -- :mod:`pcapkit` implements DTLS + now, but rewiring this dispatch through it, and deciding what an + undecrypted DTLS-wrapped NGAP PDU should report, is its own follow-up. 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 +469,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 still maps it straight 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..04c13d9f94 --- /dev/null +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -0,0 +1,599 @@ +# -*- 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. + + It used to raise, on the theory that a cleartext CCS is always 1 + octet -- true, but a CCS sent mid-renegotiation (after keys exist) + is ciphertext under the same content type, and ciphertext is never + exactly 1 octet either, given MAC/tag overhead. Same bug class as + the already-fixed content-type-21/22 cases: the fix is to keep the + fragment opaque rather than raise, 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 the one with a confirmed exploit behind it: it + used to ask whether :class:`~pcapkit.const.tls.content_type.ContentType` + had *named* the value, and ``invalid(0)`` is named -- a sentinel + :rfc:`9846#section-5.1` defines so a record carrying it can be rejected, + which IANA correspondingly leaves inside its Unassigned 0-19 band. So + ``00 03 03 00 04 deadbeef`` on TCP:443 was accepted and its fragment + decoded. It is an allowlist of the assigned 20-27 now. + + """ + + 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 + the bottom of the 32-63 band :rfc:`9147` reserves, and 255 the top of + 64-255. 27 is the other side of the boundary and must *not* be + rejected -- it is :rfc:`9853`'s ``return_routability_check``, the + newest assignment -- 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, 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_pending_cid_length_tracking(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 yet. 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 = ( + ('a genuine record', tls_good, 'TCP:TLS'), + ('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'), + ('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)) + + +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..30665bbd16 --- /dev/null +++ b/tests/protocols/application/test_tls_dtls_roundtrip_unit.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +"""TLS and DTLS records rebuild byte for byte. + +Hand-crafted wire octets for every content type and handshake-message shape +this PR implements (: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 this PR does not reassemble one -- 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'), + # :rfc:`9853`, the newest ContentType assignment. + 'return_routability_check': _tls_record(27, b'\xde\xad\xbe\xef'), +} + + +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'), +} + + +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. + + It used to raise, on the theory that any alert fragment must be + exactly 2 octets, cleartext or not -- but an encrypted alert is + never exactly 2 octets either, so a non-2-octet fragment is kept + opaque now 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 known limitation this PR documents: no reassembly yet.""" + 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..d190d8a4ec 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,9 @@ 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, + # tcp/443 (TLS) and udp/5684 (DTLS) added alongside this PR's + # dissector. + '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..307d6c2239 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 still + maps it straight 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 From 1e8efb6ec9e6fa4145b072f92910034569817a3f Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 13:04:16 -0400 Subject: [PATCH 02/11] refactor(application): share version-negotiation sniffing on SSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSL._tls_negotiates peeks a record's first handshake message for a supported_versions extension naming a target version, without propagating a malformed-message error -- the dispatchers tls.py and dtls.py add next use it to tell TLS 1.2 from TLS 1.3, and DTLS 1.2 from DTLS 1.3, both of which freeze their record-layer legacy version at their predecessor's value (RFC 9846 §4.1.3 / RFC 9147 §4.1). SSL._adopt_dispatched folds a dispatcher's own read() wrap-up -- adopting the identified tier's alias and next-layer chain -- into one shared helper, mirroring HTTP.read()'s own adoption of whichever versioned object it constructs. Keeping it on SSL rather than duplicated in tls.py and dtls.py is also what keeps pylint's duplicate-code check quiet once both dispatchers use it. No behavioural change on its own; TLS.id()/DTLS.id() stay unchanged. Build: mypy and isort clean on this file; full tests/protocols and tests/project pass. Refs #2437 --- pcapkit/protocols/application/ssl.py | 120 ++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index 28bff9e8da..005ba5919c 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -35,6 +35,7 @@ from pcapkit.const.tls.content_type import ContentType as Enum_ContentType from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType +from pcapkit.corekit.protochain import ProtoChain from pcapkit.protocols.application.application import Application from pcapkit.protocols.data.application.tls import Alert as Data_Alert from pcapkit.protocols.data.application.tls import ChangeCipherSpec as Data_ChangeCipherSpec @@ -42,6 +43,7 @@ from pcapkit.protocols.data.application.tls import Extension as Data_Extension from pcapkit.protocols.data.application.tls import Handshake as Data_Handshake from pcapkit.protocols.data.application.tls import ServerHello as Data_ServerHello +from pcapkit.protocols.misc.null import NoPayload from pcapkit.protocols.protocol import _PT, _ST from pcapkit.utilities.exceptions import ProtocolError @@ -96,6 +98,56 @@ Enum_ContentType.return_routability_check, }) +#: The IANA ``supported_versions`` extension type (:rfc:`9846#section-4.2.1`). +#: TLS 1.3 and DTLS 1.3 negotiate the *real* version here rather than in the +#: record header's own legacy version, which both freeze at their immediate +#: predecessor's value for backward compatibility -- see :data:`TLS13_VERSION` +#: and :data:`DTLS13_VERSION`. +_EXT_SUPPORTED_VERSIONS = 43 + +#: The wire value a ``supported_versions`` extension carries for TLS 1.3 +#: (:rfc:`9846#section-4.2.1`). Never the record layer's own +#: ``legacy_version``, which TLS 1.3 freezes at TLS 1.2's ``0x0303``. +TLS13_VERSION = 0x0304 +#: Same, for DTLS 1.3 (:rfc:`9147#section-5.3`). The record layer stays +#: frozen at DTLS 1.2's ``0xFEFD``. +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.2.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. @@ -267,6 +319,72 @@ def _validate_tls_record(self, schema: 'Any', *, available: 'int', dtls: 'bool') 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``. + + TLS 1.3 and DTLS 1.3 freeze the record layer's own legacy version at + their predecessor's value and negotiate the real version through a + ``supported_versions`` extension on the ``ClientHello``/``ServerHello`` + instead (:data:`TLS13_VERSION`, :data:`DTLS13_VERSION`). A dispatcher + picking a concrete version tier for an ambiguous legacy version + consults this to tell the two apart. + + This is used from a dispatcher's own version guess, *before* the real + parse that follows: any failure to decode here -- a malformed + message, a non-handshake record, ciphertext that happens not to tile + -- is reported as "no", never propagated, since the real parse the + dispatcher goes on to run is what reports a genuine error, not this + best-effort peek. + + 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 _adopt_dispatched(self, obj: 'Any') -> 'Any': + """Adopt a dispatched tier's parse result as this dispatcher's own. + + Shared by every family's version-dispatching ``read()`` -- + :meth:`TLS.read ` and + :meth:`DTLS.read ` -- + mirroring :meth:`HTTP.read `'s + own adoption of whichever versioned object it constructed: the + dispatcher's own :attr:`alias ` + becomes the identified tier's, and a next layer the tier's own parse + already decoded is carried over rather than decoded twice. + + Args: + obj: The concrete tier's own, already-parsed instance. + + Returns: + ``obj``'s parsed data model, which becomes this dispatcher's own + :attr:`~pcapkit.protocols.protocol.ProtocolBase.info`. + + """ + self._alias = obj.alias # pylint: disable=attribute-defined-outside-init + next_ = getattr(obj, '_next', None) + if next_ is not None and not isinstance(next_, NoPayload): + self._next = next_ # pylint: disable=attribute-defined-outside-init + self._protos = ProtoChain(self.__class__, self.alias, basis=next_.protochain) # pylint: disable=attribute-defined-outside-init + return obj.info + def _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. @@ -629,7 +747,7 @@ def _read_tls_client_hello(self, body: 'bytes', *, dtls: 'bool') -> 'Data_Client # octet rather than fail, and :meth:`_make_tls_client_hello` would then # re-pad it to 2 octets on rebuild, losing byte-exactness silently # rather than refusing to parse. - if cipher_suites_len % 2 != 0: + 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') From f6b24a50f4a07d6ad7dc917cbfc21a8c8e211037 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 13:04:34 -0400 Subject: [PATCH 03/11] feat(application): split TLS into a version dispatcher, add an SSLv2 entry point Per the owner's ruling on #2441: TLS should dispatch by version with a thin abstract base and per-version dispatcher, the same shape HTTP already uses over HTTPv1/HTTPv2. TLS keeps its record-layer schema and subclasses SSL as before, but now only identifies the concrete tier -- SSLv3, TLSv1_0, TLSv1_1, TLSv1_2 or TLSv1_3, new thin classes each fixing alias/id() -- and delegates; the real parse moves to TLS._read_tls_record, shared unchanged by every tier since SSLv3 through TLS 1.3 share the identical wire format this dissector already treated generically. Dispatch: SSLv3/TLSv1_0/TLSv1_1 are named directly by the record's legacy version; 0x0303 is ambiguous between TLS 1.2 and TLS 1.3, which freezes its own legacy version at TLS 1.2's value, so that case peeks the ClientHello/ServerHello's supported_versions extension (SSL._tls_negotiates) and falls back to TLS 1.2 -- the common case -- when the peek is inconclusive. Any other legacy version also falls back to TLS 1.2, so _validate_tls_record reports the real rejection reason rather than this method guessing one. SSLv2 entry point: _looks_like_sslv2 disambiguates SSLv2's content-type-less header (2-octet form, or 3-octet escaped/plain) from a genuine TLS/SSLv3+ record's content-type+0x03 pair, and TLS._guess_version delegates to SSL2 when it matches. The import is lazy and ImportError-tolerant, since #2466 (SSL2) is not yet merged; dispatch falls through to the TLS family unchanged until it is. TLS.id() deliberately does not list SSL2 -- it is checked against every instance of this class regardless of which tier it resolved to, and listing it would make genuine TLS 1.2 traffic match an SSL2 lookup. DTLS 1.3's new unified record header (#2467) is out of scope and depends on this landing first; only its legacy-format records, which reuse the shared wire format, are reachable here. Refs #2437, #2457, #2467 --- pcapkit/protocols/application/sslv3.py | 81 +++++++ pcapkit/protocols/application/tls.py | 280 ++++++++++++++++++++--- pcapkit/protocols/application/tlsv1_0.py | 75 ++++++ pcapkit/protocols/application/tlsv1_1.py | 75 ++++++ pcapkit/protocols/application/tlsv1_2.py | 83 +++++++ pcapkit/protocols/application/tlsv1_3.py | 89 +++++++ 6 files changed, 647 insertions(+), 36 deletions(-) create mode 100644 pcapkit/protocols/application/sslv3.py create mode 100644 pcapkit/protocols/application/tlsv1_0.py create mode 100644 pcapkit/protocols/application/tlsv1_1.py create mode 100644 pcapkit/protocols/application/tlsv1_2.py create mode 100644 pcapkit/protocols/application/tlsv1_3.py diff --git a/pcapkit/protocols/application/sslv3.py b/pcapkit/protocols/application/sslv3.py new file mode 100644 index 0000000000..e3e32e70ca --- /dev/null +++ b/pcapkit/protocols/application/sslv3.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""SSLv3 - Secure Sockets Layer version 3 +============================================ + +.. module:: pcapkit.protocols.application.sslv3 + +:mod:`pcapkit.protocols.application.sslv3` contains +:class:`~pcapkit.protocols.application.sslv3.SSLv3` only, the concrete tier +:class:`TLS ` delegates to for a +record whose legacy version is ``0x0300``. SSLv3 shares TLS's record-layer +wire format exactly -- the ``0x03xx`` legacy version range it and every TLS +version after it were designed to extend -- so this class contributes +nothing of its own beyond a fixed :attr:`alias` and a narrowed :meth:`id`: +the actual parse is :meth:`TLS._read_tls_record +`, shared by every +tier in the family. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.tls import TLS as TLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['SSLv3'] + + +class SSLv3(TLSBase, data=Data_TLS, schema=Schema_TLS): + """This class implements Secure Sockets Layer version 3.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Secure Sockets Layer version 3"]': # type: ignore[override] + """Name of current protocol.""" + return 'Secure Sockets Layer version 3' + + @property + def alias(self) -> 'Literal["SSLv3"]': + """Acronym of current protocol.""" + return 'SSLv3' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["TLS"], Literal["SSLv3"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('TLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': + """Read Secure Sockets Layer version 3. + + Delegates to :meth:`TLS._read_tls_record + `, shared by + every tier in the TLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index 6d833fbd49..6b9b593090 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -5,10 +5,16 @@ .. module:: pcapkit.protocols.application.tls :mod:`pcapkit.protocols.application.tls` contains -:class:`~pcapkit.protocols.application.tls.TLS` only, which implements an -extractor for Transport Layer Security (TLS) [*]_ -- TLS 1.2 (:rfc:`5246`) and -TLS 1.3 (:rfc:`9846`, which obsoletes :rfc:`8446`) -- at the record layer, plus -handshake message TYPE-level framing. +:class:`~pcapkit.protocols.application.tls.TLS`, which is a version +dispatcher 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:`8446`) +-- mirroring :class:`~pcapkit.protocols.application.http.HTTP`'s dispatch +over :mod:`~pcapkit.protocols.application.httpv1` and +:mod:`~pcapkit.protocols.application.httpv2`. :attr:`TCP.__proto__ +` binds this class at port +443; its own :meth:`read` identifies the concrete tier -- or, where the +payload is SSLv2-shaped instead, :class:`~pcapkit.protocols.application.ssl2.SSL2` +-- and delegates to it, rather than parsing content itself. Decoded Fields -------------- @@ -54,15 +60,26 @@ :meth:`SSL._validate_tls_record ` for why rejecting such a fragment is the honest answer at this layer. -.. [*] https://en.wikipedia.org/wiki/Transport_Layer_Security - .. versionadded:: 1.6.0 +.. versionchanged:: 1.6.0 + + :class:`TLS` is now a version dispatcher rather than a single generic + parser: it identifies SSLv3, TLS 1.0, TLS 1.1, TLS 1.2, or TLS 1.3 (and, + where available, SSLv2) and delegates to the matching concrete class -- + :mod:`~pcapkit.protocols.application.sslv3`, + :mod:`~pcapkit.protocols.application.tlsv1_0`, + :mod:`~pcapkit.protocols.application.tlsv1_1`, + :mod:`~pcapkit.protocols.application.tlsv1_2`, or + :mod:`~pcapkit.protocols.application.tlsv1_3` -- the same shape + :class:`~pcapkit.protocols.application.http.HTTP` already uses for its own + family (:issue:`2437`). + """ from typing import TYPE_CHECKING, cast from pcapkit.const.tls.content_type import ContentType as Enum_ContentType -from pcapkit.protocols.application.ssl import SSL +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 @@ -79,6 +96,70 @@ __all__ = ['TLS'] +#: Legacy record-version major octet TLS/SSLv3+ records carry (``0x03xx``, +#: SSLv3 through the TLS 1.3 wire value). Mirrors +#: :data:`~pcapkit.protocols.application.ssl._TLS_VERSION_MAJOR`, which this +#: module does not import -- that constant is private to +#: :mod:`~pcapkit.protocols.application.ssl` -- but the value the two check +#: is the same one by definition. +_TLS_VERSION_MAJOR = 0x03 + +#: The content types IANA's registry assigns that a genuine *first* record on +#: a fresh connection carries on the wire -- the four +#: :func:`_looks_like_sslv2` tests the second octet of, to tell a real +#: TLS/SSLv3+ record from SSLv2's wire-incompatible, content-type-less header. +#: Deliberately narrower than +#: :data:`~pcapkit.protocols.application.ssl._WIRE_CONTENT_TYPES`: ``heartbeat`` +#: and the DTLS-only ``ack``/``return_routability_check`` do not open a +#: connection, and widening this set only widens the window in which a +#: genuine SSLv2 record (whose second octet is an arbitrary length octet, not +#: always a version byte) could be misread as TLS. +_SSLV3_PLUS_FIRST_OCTETS = frozenset({ + int(Enum_ContentType.change_cipher_spec), + int(Enum_ContentType.alert), + int(Enum_ContentType.handshake), + int(Enum_ContentType.application_data), +}) + + +def _looks_like_sslv2(data: 'bytes') -> 'bool': + """Whether ``data`` opens with an SSLv2 record header rather than a TLS one. + + SSLv2 carries no content-type byte at all: its record header is either 2 + octets, with the top bit of the first set, or 3, with the top two bits + ``01`` for the escaped form or ``00`` for the plain one (see + :mod:`~pcapkit.protocols.application.ssl2`'s module docstring for the + exact bit layout). A genuine TLS/SSLv3+ record's first octet is always + one of the content types :data:`_SSLV3_PLUS_FIRST_OCTETS` names, and its + second is always :data:`_TLS_VERSION_MAJOR`. + + Only the plain (``00``) 3-octet SSLv2 form is genuinely ambiguous with a + real content-type byte, since content types 20-23 also carry top bits + ``00`` -- resolved the same way the positive identification is, by + testing the second octet too, so only a match on *both* conditions reads + as TLS; everything else, including a buffer too short to carry even one + octet, reads as SSLv2. + + Args: + data: The payload, from its first octet. + + Returns: + :data:`False` only when ``data`` opens with a content type this + module recognises as a TLS/SSLv3+ one immediately followed by + :data:`_TLS_VERSION_MAJOR`; :data:`True` otherwise. + + """ + if not data: + return True + first = data[0] + if first & 0x80: # SSLv2, 2-octet header + return True + if (first & 0xC0) == 0x40: # SSLv2, 3-octet header, escaped + return True + if first in _SSLV3_PLUS_FIRST_OCTETS and len(data) >= 2 and data[1] == _TLS_VERSION_MAJOR: + return False + return True # SSLv2, 3-octet header, plain -- or too short to tell + class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): """This class implements Transport Layer Security. @@ -92,6 +173,13 @@ class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): """ + #: Resolved alias once :meth:`read` has identified the concrete tier -- + #: e.g. ``'SSLv3'`` or ``'TLS 1.2'`` -- mirroring + #: :attr:`HTTP._version `. + #: The class name itself until then, same as the unversioned default + #: every :class:`~pcapkit.protocols.protocol.ProtocolBase` falls back to. + _alias = 'TLS' + ########################################################################## # Properties. ########################################################################## @@ -101,6 +189,11 @@ def name(self) -> 'Literal["Transport Layer Security"]': """Name of current protocol.""" return 'Transport Layer Security' + @property + def alias(self) -> 'str': + """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, @@ -114,49 +207,55 @@ def length(self) -> 'Literal[5]': # Methods. ########################################################################## - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': # pylint: disable=unused-argument - """Read Transport Layer Security (TLS). + @classmethod + def id(cls) -> 'tuple[Literal["TLS"], Literal["SSLv3"], Literal["TLSv1_0"], Literal["TLSv1_1"], Literal["TLSv1_2"], Literal["TLSv1_3"]]': # type: ignore[override] # pylint: disable=line-too-long + """Index ID of the protocol. - Structure of TLS record layer [:rfc:`9846#section-5.1`]: + Returns: + Index ID of the protocol -- the canonical name first, then every + concrete tier this dispatcher may delegate to. SSLv2 is + deliberately not among them: :meth:`read` may still delegate to + it when the payload is shaped that way (see + :func:`_looks_like_sslv2`), but this tuple is checked against + *every* instance of this class regardless of which tier it + actually resolved to, and genuine TLS 1.2 traffic must not match + an ``SSL2`` lookup just because *some* port-443 payload could + have been SSLv2. Inspect :attr:`alias`, or the type of + :attr:`info `, + to tell SSLv2 traffic apart instead. - .. code-block:: text + """ + return ('TLS', 'SSLv3', 'TLSv1_0', 'TLSv1_1', 'TLSv1_2', 'TLSv1_3') - struct { - ContentType type; - ProtocolVersion legacy_record_version; - uint16 length; - opaque fragment[TLSPlaintext.length]; - } TLSPlaintext; + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': + """Read Transport Layer Security (TLS), or whatever port 443 actually carries. + + Identifies the concrete tier -- SSLv2, SSLv3, or one of TLS 1.0 + through 1.3 -- and delegates to it, mirroring + :meth:`HTTP.read `'s + own dispatch over its family. Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. Returns: - Parsed packet data. + Parsed packet data, from whichever concrete tier :meth:`_guess_version` + identified. 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. + ProtocolError: If the identified tier's own parse fails -- see + :meth:`SSLv3._read_tls_record ` + (shared by every TLS-family tier) or + :class:`~pcapkit.protocols.application.ssl2.SSL2`'s own + :meth:`read `. """ - schema = self.__header__ - available = length if length is not None else len(self) - self._validate_tls_record(schema, available=available, dtls=False) + if length is None: + length = len(self) - 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) + obj = self._guess_version(length, **kwargs) + return self._adopt_dispatched(obj) def make(self, content_type: 'Enum_ContentType | StdlibEnum | AenumEnum | str | int' = Enum_ContentType.application_data, # pylint: disable=line-too-long @@ -170,6 +269,11 @@ def make(self, **kwargs: 'Any') -> 'Schema_TLS': """Make (construct) packet data. + Shared by every tier in the TLS family -- SSLv3 through TLS 1.3 wrap + the identical record header and content shapes, so there is nothing + version-specific for this to dispatch on; the ``version`` argument is + simply written through to the record's legacy version field. + Args: content_type: Record content type. content_type_default: Default value for ``content_type``. @@ -237,3 +341,107 @@ def _make_data(cls, data: 'Data_TLS') -> 'dict[str, Any]': # type: ignore[overr 'content': data.content, 'payload': cls._make_payload(data), } + + def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'Any': + """Identify the concrete tier of the payload, and parse it as that tier. + + Positive identification first, same philosophy as + :meth:`HTTP._guess_version `: + SSLv2 is told apart from the modern family by its wire-incompatible + header shape (:func:`_looks_like_sslv2`), not by trial-parsing. Within + the modern family, SSLv3/TLS 1.0/TLS 1.1 are named directly by the + record's legacy version; TLS 1.2 and TLS 1.3 share the same legacy + value (``0x0303``) and are told apart by peeking the + ``supported_versions`` extension (:meth:`SSL._tls_negotiates + `), falling + back to TLS 1.2 -- the common case -- when that peek is inconclusive. + Any legacy version outside SSLv3+'s ``0x03xx`` range also falls back + to the TLS 1.2 tier, so that its own :meth:`SSL._validate_tls_record + ` reports + the real reason the payload is not a genuine record, rather than this + method guessing at one. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data from whichever concrete tier was identified. + + """ + schema = self.__header__ + + if _looks_like_sslv2(self._data[:length]): + try: + from pcapkit.protocols.application.ssl2 import SSL2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + except ImportError: + pass # SSLv2 support (#2457) is not present on this + # checkout -- fall through to the TLS-family dispatch + # below, exactly as if this identification did not exist. + else: + return SSL2(self._data, length, **kwargs) + + from pcapkit.protocols.application.sslv3 import SSLv3 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.tlsv1_0 import TLSv1_0 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.tlsv1_1 import TLSv1_1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.tlsv1_2 import TLSv1_2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.tlsv1_3 import TLSv1_3 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + + major = schema.version >> 8 + minor = schema.version & 0xFF + + protocol: '_Type[TLS]' = TLSv1_2 + if major == _TLS_VERSION_MAJOR: + if not minor: + protocol = SSLv3 + elif minor == 0x01: + protocol = TLSv1_0 + elif minor == 0x02: + protocol = TLSv1_1 + elif minor == 0x03 and self._tls_negotiates(schema, TLS13_VERSION, dtls=False): + protocol = TLSv1_3 + return protocol(self._data, length, **kwargs) + + def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': # pylint: disable=unused-argument + """Read a TLS/SSLv3+ record -- the real parse every concrete tier shares. + + 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; + + 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) + + 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) diff --git a/pcapkit/protocols/application/tlsv1_0.py b/pcapkit/protocols/application/tlsv1_0.py new file mode 100644 index 0000000000..89b163876f --- /dev/null +++ b/pcapkit/protocols/application/tlsv1_0.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""TLSv1_0 - Transport Layer Security version 1.0 +===================================================== + +.. module:: pcapkit.protocols.application.tlsv1_0 + +:mod:`pcapkit.protocols.application.tlsv1_0` contains +:class:`~pcapkit.protocols.application.tlsv1_0.TLSv1_0` only, the concrete +tier :class:`TLS ` delegates to for a +record whose legacy version is ``0x0301`` [:rfc:`2246`]. TLS 1.0 shares the +record-layer wire format every tier in the family does, so this class +contributes nothing of its own beyond a fixed :attr:`alias` and a narrowed +:meth:`id`: the actual parse is :meth:`TLS._read_tls_record +`, shared by every +tier in the family. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.tls import TLS as TLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['TLSv1_0'] + + +class TLSv1_0(TLSBase, data=Data_TLS, schema=Schema_TLS): + """This class implements Transport Layer Security version 1.0.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def alias(self) -> 'Literal["TLS 1.0"]': + """Acronym of current protocol.""" + return 'TLS 1.0' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_0"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('TLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': + """Read Transport Layer Security version 1.0. + + Delegates to :meth:`TLS._read_tls_record + `, shared by + every tier in the TLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tlsv1_1.py b/pcapkit/protocols/application/tlsv1_1.py new file mode 100644 index 0000000000..e46b4bf555 --- /dev/null +++ b/pcapkit/protocols/application/tlsv1_1.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""TLSv1_1 - Transport Layer Security version 1.1 +===================================================== + +.. module:: pcapkit.protocols.application.tlsv1_1 + +:mod:`pcapkit.protocols.application.tlsv1_1` contains +:class:`~pcapkit.protocols.application.tlsv1_1.TLSv1_1` only, the concrete +tier :class:`TLS ` delegates to for a +record whose legacy version is ``0x0302`` [:rfc:`4346`]. TLS 1.1 shares the +record-layer wire format every tier in the family does, so this class +contributes nothing of its own beyond a fixed :attr:`alias` and a narrowed +:meth:`id`: the actual parse is :meth:`TLS._read_tls_record +`, shared by every +tier in the family. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.tls import TLS as TLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['TLSv1_1'] + + +class TLSv1_1(TLSBase, data=Data_TLS, schema=Schema_TLS): + """This class implements Transport Layer Security version 1.1.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def alias(self) -> 'Literal["TLS 1.1"]': + """Acronym of current protocol.""" + return 'TLS 1.1' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_1"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('TLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': + """Read Transport Layer Security version 1.1. + + Delegates to :meth:`TLS._read_tls_record + `, shared by + every tier in the TLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tlsv1_2.py b/pcapkit/protocols/application/tlsv1_2.py new file mode 100644 index 0000000000..be7277c629 --- /dev/null +++ b/pcapkit/protocols/application/tlsv1_2.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""TLSv1_2 - Transport Layer Security version 1.2 +===================================================== + +.. module:: pcapkit.protocols.application.tlsv1_2 + +:mod:`pcapkit.protocols.application.tlsv1_2` contains +:class:`~pcapkit.protocols.application.tlsv1_2.TLSv1_2` only, the concrete +tier :class:`TLS ` delegates to for a +record whose legacy version is ``0x0303`` [:rfc:`5246`] and that is not +negotiating TLS 1.3 via a ``supported_versions`` extension. ``0x0303`` is +also the legacy version TLS 1.3 freezes its own records at +(:rfc:`9846#section-4.1.3`), so this tier doubles as the generic fallback for +that ambiguous value, and for any legacy version :class:`TLS +`'s dispatch does not otherwise +recognise -- see :meth:`TLS._guess_version +`. + +TLS 1.2 shares the record-layer wire format every tier in the family does, +so this class contributes nothing of its own beyond a fixed :attr:`alias` +and a narrowed :meth:`id`: the actual parse is :meth:`TLS._read_tls_record +`, shared by every +tier in the family. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.tls import TLS as TLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['TLSv1_2'] + + +class TLSv1_2(TLSBase, data=Data_TLS, schema=Schema_TLS): + """This class implements Transport Layer Security version 1.2.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def alias(self) -> 'Literal["TLS 1.2"]': + """Acronym of current protocol.""" + return 'TLS 1.2' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_2"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('TLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': + """Read Transport Layer Security version 1.2. + + Delegates to :meth:`TLS._read_tls_record + `, shared by + every tier in the TLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tlsv1_3.py b/pcapkit/protocols/application/tlsv1_3.py new file mode 100644 index 0000000000..26f2bab07d --- /dev/null +++ b/pcapkit/protocols/application/tlsv1_3.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""TLSv1_3 - Transport Layer Security version 1.3 +===================================================== + +.. module:: pcapkit.protocols.application.tlsv1_3 + +:mod:`pcapkit.protocols.application.tlsv1_3` contains +:class:`~pcapkit.protocols.application.tlsv1_3.TLSv1_3` only, the concrete +tier :class:`TLS ` delegates to for a +record whose legacy version is ``0x0303`` [:rfc:`9846`] *and* whose +``ClientHello``/``ServerHello`` negotiates TLS 1.3 via a +``supported_versions`` extension -- TLS 1.3 freezes its own record-layer +legacy version at TLS 1.2's value for backward compatibility +(:rfc:`9846#section-4.1.3`), so the record header alone cannot tell the two +apart; see :meth:`TLS._guess_version +` and +:meth:`SSL._tls_negotiates ` +for how dispatch resolves it. + +TLS 1.3 shares the record-layer wire format every tier in the family does, +so this class contributes nothing of its own beyond a fixed :attr:`alias` +and a narrowed :meth:`id`: the actual parse is :meth:`TLS._read_tls_record +`, shared by every +tier in the family. This dissector's scope stops at message-TYPE-level +framing (:issue:`2437`), so TLS 1.3-specific content -- an encrypted +``EncryptedExtensions``, ``Certificate``, or ``Finished``, all of which this +version sends under ``application_data`` rather than their TLS 1.2 content +types -- stays the same opaque :obj:`bytes` every other version's encrypted +content does; nothing in this class decodes differently for being 1.3. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.tls import TLS as TLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['TLSv1_3'] + + +class TLSv1_3(TLSBase, data=Data_TLS, schema=Schema_TLS): + """This class implements Transport Layer Security version 1.3.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def alias(self) -> 'Literal["TLS 1.3"]': + """Acronym of current protocol.""" + return 'TLS 1.3' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_3"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('TLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': + """Read Transport Layer Security version 1.3. + + Delegates to :meth:`TLS._read_tls_record + `, shared by + every tier in the TLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_tls_record(length, **kwargs) From a0b66a358f89ff06197e5852a670ed61ba532a49 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 13:04:50 -0400 Subject: [PATCH 04/11] feat(application): split DTLS into a version dispatcher over its own family Mirrors the TLS restructure in the previous commit, for DTLS's own family: DTLS 1.0 (RFC 4347), DTLS 1.2 (RFC 6347), and DTLS 1.3's legacy-format records (RFC 9147). DTLS keeps its record-layer schema and subclasses SSL as before, but now only identifies the concrete tier -- DTLSv1_0, DTLSv1_2 or DTLSv1_3, new thin classes each fixing alias/id() -- and delegates; the real parse moves to DTLS._read_dtls_record, shared unchanged since all three tiers share the identical DTLSPlaintext wire format this dissector already treated generically. DTLS 1.0 and DTLS 1.2 are named directly by the record's legacy version (0xFEFF / 0xFEFD); DTLS 1.3's legacy-format records freeze theirs at DTLS 1.2's value, so that case peeks the ClientHello/ServerHello's supported_versions extension the same way TLS 1.2/1.3 are told apart, falling back to DTLS 1.2 -- the common case -- when the peek is inconclusive. No SSLv2 analogue exists here: SSLv2 historically ran over TCP only. DTLS 1.3's unified record header (#2467, blocked on this landing first) stays undecoded: its first octet never falls inside the assigned content-type range SSL._validate_tls_record checks, so it degrades to Raw rather than being misdecoded as a legacy-format record. Refs #2437, #2467 --- pcapkit/protocols/application/dtls.py | 235 ++++++++++++++++------ pcapkit/protocols/application/dtlsv1_0.py | 75 +++++++ pcapkit/protocols/application/dtlsv1_2.py | 83 ++++++++ pcapkit/protocols/application/dtlsv1_3.py | 100 +++++++++ 4 files changed, 437 insertions(+), 56 deletions(-) create mode 100644 pcapkit/protocols/application/dtlsv1_0.py create mode 100644 pcapkit/protocols/application/dtlsv1_2.py create mode 100644 pcapkit/protocols/application/dtlsv1_3.py diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py index c2acbec825..279fdc8ea5 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -5,12 +5,17 @@ .. 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.2 -(:rfc:`6347`) and DTLS 1.3 (:rfc:`9147`) -- at the record layer, plus the +:class:`~pcapkit.protocols.application.dtls.DTLS`, which is a version +dispatcher 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`) -- 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. +set the family apart from :class:`~pcapkit.protocols.application.tls.TLS`, +whose reliable, ordered transport needs neither, stay shared across all +three tiers via :class:`~pcapkit.protocols.application.ssl.SSL`. +:attr:`UDP.__proto__ ` binds +this class at port 5684; its own :meth:`read` identifies the concrete tier +and delegates to it, rather than parsing content itself -- the same shape +:class:`~pcapkit.protocols.application.tls.TLS` uses for its own family. Known Limitation ----------------- @@ -32,15 +37,38 @@ the common case: a ``ClientHello`` or ``ServerHello`` that fits one datagram never fragments. -.. [*] https://en.wikipedia.org/wiki/Datagram_Transport_Layer_Security +**DTLS 1.3's unified record header is not decoded** (:issue:`2467`, which +depends on this PR landing first). DTLS 1.3 records before the handshake +completes still use the legacy ``DTLSPlaintext`` header this module reads -- +the same shape DTLS 1.2 uses, with ``legacy_record_version`` frozen at DTLS +1.2's value (:rfc:`9147#section-4.1`) -- so :class:`DTLSv1_3 +` decodes exactly that +traffic. The *unified header* :rfc:`9147#section-4` introduces for records +once a connection has a negotiated epoch has no content-type byte at all, so +:meth:`SSL._validate_tls_record ` +rejects it outright -- its first octet never falls inside the assigned +content-type range this module checks -- and it degrades to +:class:`~pcapkit.protocols.misc.raw.Raw`, which is the honest answer until +that follow-up lands. .. versionadded:: 1.6.0 +.. versionchanged:: 1.6.0 + + :class:`DTLS` is now a version dispatcher rather than a single generic + parser: it identifies DTLS 1.0, DTLS 1.2, or DTLS 1.3's legacy-format + records and delegates to the matching concrete class -- + :mod:`~pcapkit.protocols.application.dtlsv1_0`, + :mod:`~pcapkit.protocols.application.dtlsv1_2`, or + :mod:`~pcapkit.protocols.application.dtlsv1_3` -- the same shape + :class:`~pcapkit.protocols.application.tls.TLS` already uses for its own + family (:issue:`2437`). + """ from typing import TYPE_CHECKING, cast from pcapkit.const.tls.content_type import ContentType as Enum_ContentType -from pcapkit.protocols.application.ssl import SSL +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 @@ -57,6 +85,12 @@ __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.1`). +_DTLSV1_2_VERSION = 0xFEFD + class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): """This class implements Datagram Transport Layer Security. @@ -76,6 +110,11 @@ class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): """ + #: Resolved alias once :meth:`read` has identified the concrete tier -- + #: e.g. ``'DTLS 1.2'`` -- mirroring + #: :attr:`TLS._alias `. + _alias = 'DTLS' + ########################################################################## # Properties. ########################################################################## @@ -85,6 +124,11 @@ def name(self) -> 'Literal["Datagram Transport Layer Security"]': """Name of current protocol.""" return 'Datagram Transport Layer Security' + @property + def alias(self) -> 'str': + """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, @@ -98,69 +142,43 @@ def length(self) -> 'Literal[13]': # Methods. ########################################################################## - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': # pylint: disable=unused-argument - """Read Datagram Transport Layer Security (DTLS). + @classmethod + def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_0"], Literal["DTLSv1_2"], Literal["DTLSv1_3"]]': # type: ignore[override] # pylint: disable=line-too-long + """Index ID of the protocol. - Structure of DTLS record layer, the ``DTLSPlaintext`` form - [:rfc:`9147#section-4`] -- the full header this module reads; - ``DTLSCiphertext``, defined in that same section, is instead the - on-the-wire *compact* header an encrypted record may use once epoch 0 - has passed, which this module does not decode: + Returns: + Index ID of the protocol -- the canonical name first, then every + concrete tier this dispatcher may delegate to. - .. code-block:: text + """ + return ('DTLS', 'DTLSv1_0', 'DTLSv1_2', 'DTLSv1_3') - struct { - ContentType type; - ProtocolVersion legacy_record_version; - uint16 epoch = 0; - uint48 sequence_number; - uint16 length; - opaque fragment[DTLSPlaintext.length]; - } DTLSPlaintext; + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': + """Read Datagram Transport Layer Security (DTLS). + + Identifies the concrete tier -- DTLS 1.0, DTLS 1.2, or DTLS 1.3's + legacy-format records -- and delegates to it, mirroring + :meth:`TLS.read `'s own + dispatch over its family. Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. Returns: - Parsed packet data. + Parsed packet data, from whichever concrete tier :meth:`_guess_version` + identified. 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. - - Notes: - A ``handshake``, ``alert``, or ``change_cipher_spec`` record is - decoded structurally only at epoch 0, the cleartext initial - handshake; at any later epoch the same content types carry - ciphertext, whose octets are kept as-is -- an encrypted - ``alert`` (including the ``close_notify`` that ends nearly every - TLS 1.2 connection) is the same shape of problem as an encrypted - ``handshake``, just one epoch later than the initial flight. See - :meth:`SSL._tls_content_is_cleartext - `. + ProtocolError: If the identified tier's own parse fails -- see + :meth:`_read_dtls_record`, shared by every tier. """ - schema = self.__header__ - available = length if length is not None else len(self) - self._validate_tls_record(schema, available=available, dtls=True) - - 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, - ) + if length is None: + length = len(self) - remaining = available - (self.length + schema.length) - return self._decode_next_layer(dtls, -1, remaining) + obj = self._guess_version(length, **kwargs) + return self._adopt_dispatched(obj) def make(self, content_type: 'Enum_ContentType | StdlibEnum | AenumEnum | str | int' = Enum_ContentType.application_data, # pylint: disable=line-too-long @@ -176,6 +194,12 @@ def make(self, **kwargs: 'Any') -> 'Schema_DTLS': """Make (construct) packet data. + Shared by every tier in the DTLS family -- DTLS 1.0 through 1.3's + legacy-format records wrap the identical record header and content + shapes, so there is nothing version-specific for this to dispatch + on; the ``version`` argument is simply written through to the + record's legacy version field. + Args: content_type: Record content type. content_type_default: Default value for ``content_type``. @@ -249,3 +273,102 @@ def _make_data(cls, data: 'Data_DTLS') -> 'dict[str, Any]': # type: ignore[over 'content': data.content, 'payload': cls._make_payload(data), } + + def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'Any': + """Identify the concrete tier of the payload, and parse it as that tier. + + DTLS 1.0 and DTLS 1.2 are named directly by the record's legacy + version. DTLS 1.3's legacy-format records freeze theirs at DTLS + 1.2's value, and are told apart by peeking the + ``supported_versions`` extension (:meth:`SSL._tls_negotiates + `), falling + back to DTLS 1.2 -- the common case -- when that peek is + inconclusive. Any legacy version naming neither falls back to the + DTLS 1.2 tier too, so that its own :meth:`SSL._validate_tls_record + ` reports + the real reason the payload is not a genuine record. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data from whichever concrete tier was identified. + + """ + from pcapkit.protocols.application.dtlsv1_0 import DTLSv1_0 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.dtlsv1_2 import DTLSv1_2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.dtlsv1_3 import DTLSv1_3 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + + schema = self.__header__ + + protocol: '_Type[DTLS]' = DTLSv1_2 + if schema.version == _DTLSV1_0_VERSION: + protocol = DTLSv1_0 + elif schema.version == _DTLSV1_2_VERSION and self._tls_negotiates(schema, DTLS13_VERSION, dtls=True): + protocol = DTLSv1_3 + return protocol(self._data, length, **kwargs) + + def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': # pylint: disable=unused-argument + """Read a DTLS record -- the real parse every concrete tier shares. + + Structure of DTLS record layer, the ``DTLSPlaintext`` form + [:rfc:`9147#section-4`] -- the full header this module reads; + ``DTLSCiphertext``, defined in that same section, is instead the + on-the-wire *compact* header an encrypted record may use once epoch 0 + has passed, which this module does not decode: + + .. code-block:: text + + struct { + ContentType type; + ProtocolVersion legacy_record_version; + uint16 epoch = 0; + uint48 sequence_number; + uint16 length; + opaque fragment[DTLSPlaintext.length]; + } DTLSPlaintext; + + 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. + + Notes: + A ``handshake``, ``alert``, or ``change_cipher_spec`` record is + decoded structurally only at epoch 0, the cleartext initial + handshake; at any later epoch the same content types carry + ciphertext, whose octets are kept as-is -- an encrypted + ``alert`` (including the ``close_notify`` that ends nearly every + TLS 1.2 connection) is the same shape of problem as an encrypted + ``handshake``, just one epoch later than the initial flight. See + :meth:`SSL._tls_content_is_cleartext + `. + + """ + schema = self.__header__ + available = length if length is not None else len(self) + self._validate_tls_record(schema, available=available, dtls=True) + + 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/dtlsv1_0.py b/pcapkit/protocols/application/dtlsv1_0.py new file mode 100644 index 0000000000..95226f79e5 --- /dev/null +++ b/pcapkit/protocols/application/dtlsv1_0.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""DTLSv1_0 - Datagram Transport Layer Security version 1.0 +=============================================================== + +.. module:: pcapkit.protocols.application.dtlsv1_0 + +:mod:`pcapkit.protocols.application.dtlsv1_0` contains +:class:`~pcapkit.protocols.application.dtlsv1_0.DTLSv1_0` only, the concrete +tier :class:`DTLS ` delegates to +for a record whose legacy version is ``0xFEFF`` [:rfc:`4347`]. DTLS 1.0 +shares the record-layer wire format every tier in the family does, so this +class contributes nothing of its own beyond a fixed :attr:`alias` and a +narrowed :meth:`id`: the actual parse is :meth:`DTLS._read_dtls_record +`, shared by +every tier in the family. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.dtls import DTLS as DTLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['DTLSv1_0'] + + +class DTLSv1_0(DTLSBase, data=Data_DTLS, schema=Schema_DTLS): + """This class implements Datagram Transport Layer Security version 1.0.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def alias(self) -> 'Literal["DTLS 1.0"]': + """Acronym of current protocol.""" + return 'DTLS 1.0' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_0"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('DTLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': + """Read Datagram Transport Layer Security version 1.0. + + Delegates to :meth:`DTLS._read_dtls_record + `, shared + by every tier in the DTLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_dtls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/dtlsv1_2.py b/pcapkit/protocols/application/dtlsv1_2.py new file mode 100644 index 0000000000..8318654261 --- /dev/null +++ b/pcapkit/protocols/application/dtlsv1_2.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""DTLSv1_2 - Datagram Transport Layer Security version 1.2 +=============================================================== + +.. module:: pcapkit.protocols.application.dtlsv1_2 + +:mod:`pcapkit.protocols.application.dtlsv1_2` contains +:class:`~pcapkit.protocols.application.dtlsv1_2.DTLSv1_2` only, the concrete +tier :class:`DTLS ` delegates to +for a record whose legacy version is ``0xFEFD`` [:rfc:`6347`] and that is +not negotiating DTLS 1.3 via a ``supported_versions`` extension. ``0xFEFD`` +is also the legacy version DTLS 1.3 freezes its own legacy-format records +at (:rfc:`9147#section-4.1`), so this tier doubles as the generic fallback +for that ambiguous value, and for any legacy version :class:`DTLS +`'s dispatch does not otherwise +recognise -- see :meth:`DTLS._guess_version +`. + +DTLS 1.2 shares the record-layer wire format every tier in the family does, +so this class contributes nothing of its own beyond a fixed :attr:`alias` +and a narrowed :meth:`id`: the actual parse is :meth:`DTLS._read_dtls_record +`, shared by +every tier in the family. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.dtls import DTLS as DTLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['DTLSv1_2'] + + +class DTLSv1_2(DTLSBase, data=Data_DTLS, schema=Schema_DTLS): + """This class implements Datagram Transport Layer Security version 1.2.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def alias(self) -> 'Literal["DTLS 1.2"]': + """Acronym of current protocol.""" + return 'DTLS 1.2' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_2"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('DTLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': + """Read Datagram Transport Layer Security version 1.2. + + Delegates to :meth:`DTLS._read_dtls_record + `, shared + by every tier in the DTLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_dtls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/dtlsv1_3.py b/pcapkit/protocols/application/dtlsv1_3.py new file mode 100644 index 0000000000..72476b59dd --- /dev/null +++ b/pcapkit/protocols/application/dtlsv1_3.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""DTLSv1_3 - Datagram Transport Layer Security version 1.3 +=============================================================== + +.. module:: pcapkit.protocols.application.dtlsv1_3 + +:mod:`pcapkit.protocols.application.dtlsv1_3` contains +:class:`~pcapkit.protocols.application.dtlsv1_3.DTLSv1_3` only, the concrete +tier :class:`DTLS ` delegates to +for a record whose legacy version is ``0xFEFD`` [:rfc:`9147`] *and* whose +``ClientHello``/``ServerHello`` negotiates DTLS 1.3 via a +``supported_versions`` extension -- DTLS 1.3 freezes its own legacy-format +records at DTLS 1.2's value for backward compatibility +(:rfc:`9147#section-4.1`), so the record header alone cannot tell the two +apart; see :meth:`DTLS._guess_version +` and +:meth:`SSL._tls_negotiates ` +for how dispatch resolves it. + +**Only DTLS 1.3's legacy-format records are decoded here** -- the +``DTLSPlaintext`` shape every tier in the family shares, which DTLS 1.3 +keeps for the records exchanged before a connection has a negotiated epoch +(:rfc:`9147#section-4.1`). The *unified header* format :rfc:`9147#section-4` +introduces once epoch negotiation completes is not decoded by this class or +any other in this family (:issue:`2467`, which depends on this restructure +landing first): its first octet carries no content-type byte at all, so +:meth:`SSL._validate_tls_record +` rejects it and +it degrades to :class:`~pcapkit.protocols.misc.raw.Raw` rather than being +misdecoded as a legacy-format record. + +DTLS 1.3's legacy-format records share the record-layer wire format every +tier in the family does, so this class contributes nothing of its own +beyond a fixed :attr:`alias` and a narrowed :meth:`id`: the actual parse is +:meth:`DTLS._read_dtls_record +`, shared by +every tier in the family. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING + +from pcapkit.protocols.application.dtls import DTLS as DTLSBase +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 typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['DTLSv1_3'] + + +class DTLSv1_3(DTLSBase, data=Data_DTLS, schema=Schema_DTLS): + """This class implements Datagram Transport Layer Security version 1.3 + (legacy-format records only; see the module docstring). + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def alias(self) -> 'Literal["DTLS 1.3"]': + """Acronym of current protocol.""" + return 'DTLS 1.3' + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_3"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('DTLS', cls.__name__) # type: ignore[return-value] + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': + """Read Datagram Transport Layer Security version 1.3 (legacy-format records). + + Delegates to :meth:`DTLS._read_dtls_record + `, shared + by every tier in the DTLS family. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + return self._read_dtls_record(length, **kwargs) From b024f5be8fc954f02b86652b57b9982a327bab1e Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 13:05:06 -0400 Subject: [PATCH 05/11] test(application): cover TLS/DTLS version dispatch and the SSLv2 heuristic VersionDispatchTests exercises TLS._guess_version/DTLS._guess_version end to end: each of SSLv3/TLSv1_0/TLSv1_1 by legacy version alone, TLS 1.2 vs. TLS 1.3 and DTLS 1.2 vs. DTLS 1.3 by the supported_versions extension (both the negotiating and the not-negotiating-1.3 case), that every concrete tier is constructible directly with the right alias and narrowed id(), that both dispatchers' id() name every tier canonical-first, and that a malformed ClientHello still raises through the dispatcher rather than being swallowed by the version-identification sniff. SSLv2HeuristicTests covers _looks_like_sslv2 directly -- the 2-octet and both 3-octet SSLv2 header forms, a genuine TLS record on each of the four common content types, and the too-short case -- plus one end-to-end case confirming an SSLv2-shaped port-443 payload degrades to Raw rather than crashing when SSL2 (#2457) is not present on the checkout. The two pre-existing protochain assertions in RecordValidationTests.test_a_rejected_record_degrades_to_raw_rather_than_propagating are updated from the dispatcher's generic alias ("TLS"/"DTLS") to the identified tier's ("TLS 1.2"/"DTLS 1.2"), the same convention HTTP's own dispatch test already uses. Build/test: the full test_ssl_tls_dtls_unit.py and test_tls_dtls_roundtrip_unit.py suites pass (56 tests, 71 subtests); tests/protocols passes in full (2062 passed, 51084 subtests); the two tests/project failures this restructure could plausibly have caused (plain-comment issue-ref role, re-inlined homed content-type numbers) are fixed in the earlier commits and now pass. Refs #2437 --- .../application/test_ssl_tls_dtls_unit.py | 243 +++++++++++++++++- 1 file changed, 241 insertions(+), 2 deletions(-) diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index 04c13d9f94..2a11735765 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -373,7 +373,10 @@ def test_a_rejected_record_degrades_to_raw_rather_than_propagating(self) -> None tls_good = b'\x16\x03\x03\x00\x04\x0e\x00\x00\x00' tls_cases = ( - ('a genuine record', tls_good, 'TCP:TLS'), + # 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'), @@ -390,7 +393,7 @@ def test_a_rejected_record_degrades_to_raw_rather_than_propagating(self) -> None dtls_cases = ( ('a genuine record', b'\x16' + dtls_header + (12).to_bytes(2, 'big') + b'\x0e\x00\x00\x00' + bytes(8), - 'UDP:DTLS'), + 'UDP:DTLS 1.2'), ('content type 0', b'\x00' + dtls_header + (4).to_bytes(2, 'big') + b'\xde\xad\xbe\xef', 'UDP:Raw'), @@ -595,5 +598,241 @@ def test_a_dtls_change_cipher_spec_at_epoch_zero_with_the_wrong_length_raises(se DTLS(wire, len(wire)) +def _tls_client_hello_wire(*, supported_versions: 'bytes | None' = None) -> 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. + + """ + 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\x03\x03' + len(handshake).to_bytes(2, 'big') + handshake + + +def _dtls_client_hello_wire(*, supported_versions: 'bytes | None' = None) -> 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\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + return record_header + len(handshake).to_bytes(2, 'big') + handshake + + +class VersionDispatchTests(unittest.TestCase): + """:meth:`TLS._guess_version`/:meth:`DTLS._guess_version` -- the restructure + :issue:`2437`'s follow-up asked for: a thin per-version dispatcher over + the family, mirroring :class:`~pcapkit.protocols.application.http.HTTP`'s + own dispatch over HTTP/1.* and HTTP/2. + + """ + + 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_concrete_tiers_are_constructible_directly_and_narrow_id(self) -> None: + """Each concrete tier works standalone, bypassing dispatch entirely.""" + from pcapkit.protocols.application.dtlsv1_0 import DTLSv1_0 + from pcapkit.protocols.application.dtlsv1_2 import DTLSv1_2 + from pcapkit.protocols.application.dtlsv1_3 import DTLSv1_3 + from pcapkit.protocols.application.sslv3 import SSLv3 + from pcapkit.protocols.application.tlsv1_0 import TLSv1_0 + from pcapkit.protocols.application.tlsv1_1 import TLSv1_1 + from pcapkit.protocols.application.tlsv1_2 import TLSv1_2 + from pcapkit.protocols.application.tlsv1_3 import TLSv1_3 + + tls_wire = b'\x17\x03\x03\x00\x05hello' + for cls, alias in ((SSLv3, 'SSLv3'), (TLSv1_0, 'TLS 1.0'), (TLSv1_1, 'TLS 1.1'), + (TLSv1_2, 'TLS 1.2'), (TLSv1_3, 'TLS 1.3')): + with self.subTest(cls=cls.__name__): + parsed = cls(tls_wire, len(tls_wire)) + self.assertEqual(parsed.alias, alias) + self.assertEqual(parsed.info.content, b'hello') + self.assertEqual(cls.id(), ('TLS', cls.__name__)) + + dtls_wire = (b'\x17\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + + (5).to_bytes(2, 'big') + b'hello') + for cls, alias in ((DTLSv1_0, 'DTLS 1.0'), (DTLSv1_2, 'DTLS 1.2'), (DTLSv1_3, 'DTLS 1.3')): + with self.subTest(cls=cls.__name__): + parsed = cls(dtls_wire, len(dtls_wire)) + self.assertEqual(parsed.alias, alias) + self.assertEqual(parsed.info.content, b'hello') + self.assertEqual(cls.id(), ('DTLS', cls.__name__)) + + def test_dispatcher_id_names_every_concrete_tier_canonical_first(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.application.tls import TLS + + self.assertEqual(TLS.id(), ('TLS', 'SSLv3', 'TLSv1_0', 'TLSv1_1', 'TLSv1_2', 'TLSv1_3')) + self.assertEqual(DTLS.id(), ('DTLS', 'DTLSv1_0', 'DTLSv1_2', 'DTLSv1_3')) + + 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 SSLv2HeuristicTests(unittest.TestCase): + """:func:`pcapkit.protocols.application.tls._looks_like_sslv2` -- the + disambiguation :meth:`TLS._guess_version + ` uses to route + port-443 traffic to :class:`~pcapkit.protocols.application.ssl2.SSL2` + rather than the TLS family. + + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_two_octet_sslv2_header_is_recognised(self) -> None: + from pcapkit.protocols.application.tls import _looks_like_sslv2 + + self.assertTrue(_looks_like_sslv2(b'\x81\x00\x01')) + + def test_three_octet_escaped_sslv2_header_is_recognised(self) -> None: + from pcapkit.protocols.application.tls import _looks_like_sslv2 + + self.assertTrue(_looks_like_sslv2(b'\x41\x00\x01')) + + def test_three_octet_plain_sslv2_header_is_recognised(self) -> None: + """The genuinely ambiguous case: top bits ``00``, like a real content type.""" + from pcapkit.protocols.application.tls import _looks_like_sslv2 + + # Second octet is not 0x03, so this cannot be a TLS record either. + self.assertTrue(_looks_like_sslv2(b'\x01\x00\x01')) + + def test_a_genuine_tls_record_is_not_misread_as_sslv2(self) -> None: + from pcapkit.protocols.application.tls import _looks_like_sslv2 + + for content_type in (0x14, 0x15, 0x16, 0x17): + with self.subTest(content_type=hex(content_type)): + self.assertFalse(_looks_like_sslv2(bytes([content_type, 0x03, 0x03]))) + + def test_too_short_to_tell_reads_as_sslv2(self) -> None: + from pcapkit.protocols.application.tls import _looks_like_sslv2 + + self.assertTrue(_looks_like_sslv2(b'')) + self.assertTrue(_looks_like_sslv2(b'\x16')) # no second octet to check + + def test_sslv2_shaped_payload_degrades_to_raw_when_ssl2_is_unavailable(self) -> None: + """SSLv2 support (:issue:`2457`) is not on this checkout. + + Dispatch still identifies the shape and tries to import + :class:`~pcapkit.protocols.application.ssl2.SSL2`; the + :exc:`ImportError` falls through to the TLS-family dispatch, whose + own validation then rejects the payload -- degrading to + :class:`~pcapkit.protocols.misc.raw.Raw` rather than crashing. + + """ + from pcapkit.protocols.transport.tcp import TCP + + sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header + raw = _TCP_TO_443 + sslv2_shaped + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:Raw') + + if __name__ == '__main__': unittest.main() From 077f9e010178fe24b1da874a39e18ad946bd1447 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 13:53:01 -0400 Subject: [PATCH 06/11] refactor(application): collapse TLS/DTLS version dispatch onto ARP's dynamic-alias shape The owner settled on a simpler design than the per-version-class split this branch had built: mirror ARP's dynamic dispatch over RARP/DRARP/InARP by operation code, rather than HTTP's dispatch over distinct per-version classes. - Delete tlsv1_0.py, tlsv1_1.py, tlsv1_2.py, tlsv1_3.py, dtlsv1_0.py, dtlsv1_2.py, dtlsv1_3.py, and the old sslv3.py; fold their read()/make() logic into TLS and DTLS directly. - TLS.read()/DTLS.read() parse the record, then set self._alias (and, for SSLv3, self._name) from the legacy version -- self stays a TLS/DTLS instance throughout, never a distinct per-version object. - TLS.id()/DTLS.id() are now fixed at ('TLS',)/('DTLS',), never a multi- member tuple keyed on the dispatched tier. - Keep a thin SSLv3(TLS) class, for explicit/manual construction only -- mirroring InARP's relationship to ARP. TLS.read()'s own dispatch never constructs one. No DTLS-side equivalent: DTLS 1.0/1.2/1.3 need no separate class any more than TLS 1.0/1.1/1.3 do. - Preserve the SSLv2 entry-point heuristic and the TLS-1.2-vs-1.3 supported_versions peek unchanged; both work the same way against a single dynamically-aliased instance. - Update test_ssl_tls_dtls_unit.py's VersionDispatchTests for the new shape: fixed id(), SSLv3's own narrowed id() and non-forcing alias, and SSLv3's dynamic name. Build: isort/pylint (10.00/10 on tls.py/dtls.py)/mypy clean on the touched files, zero new mypy errors package-wide. Tests: test_ssl_tls_dtls_unit.py (47/47) + test_tls_dtls_roundtrip_unit.py (11/11), full tests/protocols (2064 passed/0 failed/51076 subtests), full tests/project (no TLS/DTLS/SSL failures; the 182 failures there are pre-existing environment gaps -- missing network access and optional extras -- unrelated to this change). --- .../pcapkit/protocols/application/tls.rst | 6 + pcapkit/protocols/application/dtls.py | 150 +++++----- pcapkit/protocols/application/dtlsv1_0.py | 75 ----- pcapkit/protocols/application/dtlsv1_2.py | 83 ------ pcapkit/protocols/application/dtlsv1_3.py | 100 ------- pcapkit/protocols/application/sslv3.py | 81 ------ pcapkit/protocols/application/tls.py | 260 ++++++++++-------- pcapkit/protocols/application/tlsv1_0.py | 75 ----- pcapkit/protocols/application/tlsv1_1.py | 75 ----- pcapkit/protocols/application/tlsv1_2.py | 83 ------ pcapkit/protocols/application/tlsv1_3.py | 89 ------ .../application/test_ssl_tls_dtls_unit.py | 114 +++++--- 12 files changed, 296 insertions(+), 895 deletions(-) delete mode 100644 pcapkit/protocols/application/dtlsv1_0.py delete mode 100644 pcapkit/protocols/application/dtlsv1_2.py delete mode 100644 pcapkit/protocols/application/dtlsv1_3.py delete mode 100644 pcapkit/protocols/application/sslv3.py delete mode 100644 pcapkit/protocols/application/tlsv1_0.py delete mode 100644 pcapkit/protocols/application/tlsv1_1.py delete mode 100644 pcapkit/protocols/application/tlsv1_2.py delete mode 100644 pcapkit/protocols/application/tlsv1_3.py diff --git a/docs/source/pcapkit/protocols/application/tls.rst b/docs/source/pcapkit/protocols/application/tls.rst index c6ebf8250f..44347e04fd 100644 --- a/docs/source/pcapkit/protocols/application/tls.rst +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -75,6 +75,12 @@ for why rejecting such a fragment is the honest answer at this layer. .. automethod:: _make_data +.. autoclass:: pcapkit.protocols.application.tls.SSLv3 + :no-members: + :show-inheritance: + + .. automethod:: id + Header Schema ------------- diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py index 279fdc8ea5..aa86041926 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -5,17 +5,21 @@ .. module:: pcapkit.protocols.application.dtls :mod:`pcapkit.protocols.application.dtls` contains -:class:`~pcapkit.protocols.application.dtls.DTLS`, which is a version -dispatcher for the DTLS record-layer family -- DTLS 1.0 (:rfc:`4347`), DTLS +: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`) -- the epoch/sequence-number fields and the handshake-message fragment header that set the family apart from :class:`~pcapkit.protocols.application.tls.TLS`, -whose reliable, ordered transport needs neither, stay shared across all -three tiers via :class:`~pcapkit.protocols.application.ssl.SSL`. +whose reliable, ordered transport needs neither, stay shared across the +whole family via :class:`~pcapkit.protocols.application.ssl.SSL`. :attr:`UDP.__proto__ ` binds -this class at port 5684; its own :meth:`read` identifies the concrete tier -and delegates to it, rather than parsing content itself -- the same shape -:class:`~pcapkit.protocols.application.tls.TLS` uses for its own family. +this class at port 5684; its own :meth:`read` parses the (always +wire-identical) record header, then sets :attr:`~DTLS.alias` according to +the record's legacy version -- mirroring +:class:`~pcapkit.protocols.link.arp.ARP`'s own dynamic dispatch over +RARP/DRARP/InARP by operation code, and +:class:`~pcapkit.protocols.application.tls.TLS`'s own dispatch over its +family. Known Limitation ----------------- @@ -41,10 +45,10 @@ depends on this PR landing first). DTLS 1.3 records before the handshake completes still use the legacy ``DTLSPlaintext`` header this module reads -- the same shape DTLS 1.2 uses, with ``legacy_record_version`` frozen at DTLS -1.2's value (:rfc:`9147#section-4.1`) -- so :class:`DTLSv1_3 -` decodes exactly that -traffic. The *unified header* :rfc:`9147#section-4` introduces for records -once a connection has a negotiated epoch has no content-type byte at all, so +1.2's value (:rfc:`9147#section-4.1`) -- so a DTLS 1.3 record identified by +:meth:`read` decodes exactly that traffic. The *unified header* +:rfc:`9147#section-4` introduces for records once a connection has a +negotiated epoch has no content-type byte at all, so :meth:`SSL._validate_tls_record ` rejects it outright -- its first octet never falls inside the assigned content-type range this module checks -- and it degrades to @@ -55,14 +59,11 @@ .. versionchanged:: 1.6.0 - :class:`DTLS` is now a version dispatcher rather than a single generic - parser: it identifies DTLS 1.0, DTLS 1.2, or DTLS 1.3's legacy-format - records and delegates to the matching concrete class -- - :mod:`~pcapkit.protocols.application.dtlsv1_0`, - :mod:`~pcapkit.protocols.application.dtlsv1_2`, or - :mod:`~pcapkit.protocols.application.dtlsv1_3` -- the same shape - :class:`~pcapkit.protocols.application.tls.TLS` already uses for its own - family (:issue:`2437`). + :class:`DTLS` identifies DTLS 1.0, DTLS 1.2, or DTLS 1.3's legacy-format + records and reports the identified tier through :attr:`~DTLS.alias` -- + :data:`self ` throughout, never a distinct per-version instance -- + the same shape :class:`~pcapkit.protocols.link.arp.ARP` already uses for + its own family (:issue:`2437`). """ from typing import TYPE_CHECKING, cast @@ -74,8 +75,7 @@ if TYPE_CHECKING: from enum import IntEnum as StdlibEnum - from typing import Any, Optional - from typing import Type as _Type + from typing import Any, Optional, Type from aenum import IntEnum as AenumEnum from typing_extensions import Literal @@ -93,7 +93,11 @@ class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): - """This class implements Datagram Transport Layer Security. + """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 @@ -111,9 +115,10 @@ class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): """ #: Resolved alias once :meth:`read` has identified the concrete tier -- - #: e.g. ``'DTLS 1.2'`` -- mirroring - #: :attr:`TLS._alias `. - _alias = 'DTLS' + #: e.g. ``'DTLS 1.2'``. The class name itself until then, same as the + #: unversioned default every :class:`~pcapkit.protocols.protocol.ProtocolBase` + #: falls back to. Mirrors :attr:`ARP._acnm `. + _alias = 'DTLS' # type: Literal["DTLS", "DTLS 1.0", "DTLS 1.2", "DTLS 1.3"] ########################################################################## # Properties. @@ -125,7 +130,7 @@ def name(self) -> 'Literal["Datagram Transport Layer Security"]': return 'Datagram Transport Layer Security' @property - def alias(self) -> 'str': + 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 @@ -143,47 +148,44 @@ def length(self) -> 'Literal[13]': ########################################################################## @classmethod - def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_0"], Literal["DTLSv1_2"], Literal["DTLSv1_3"]]': # type: ignore[override] # pylint: disable=line-too-long + def id(cls) -> 'tuple[Literal["DTLS"]]': # type: ignore[override] """Index ID of the protocol. Returns: - Index ID of the protocol -- the canonical name first, then every - concrete tier this dispatcher may delegate to. + Index ID of the protocol. Fixed, regardless of which tier + :meth:`read` identifies the record as -- see + :meth:`TLS.id ` for why. """ - return ('DTLS', 'DTLSv1_0', 'DTLSv1_2', 'DTLSv1_3') + return ('DTLS',) - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': """Read Datagram Transport Layer Security (DTLS). - Identifies the concrete tier -- DTLS 1.0, DTLS 1.2, or DTLS 1.3's - legacy-format records -- and delegates to it, mirroring - :meth:`TLS.read `'s own - dispatch over its family. + Parses the record and identifies its concrete tier -- DTLS 1.0, DTLS + 1.2, or DTLS 1.3's legacy-format records -- setting :attr:`alias` + accordingly, mirroring :meth:`TLS.read + `'s own dispatch over its + family. Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. Returns: - Parsed packet data, from whichever concrete tier :meth:`_guess_version` - identified. + Parsed packet data. Raises: - ProtocolError: If the identified tier's own parse fails -- see - :meth:`_read_dtls_record`, shared by every tier. + ProtocolError: If the record cannot be a genuine DTLS record -- + see :meth:`_read_dtls_record`. """ - if length is None: - length = len(self) - - obj = self._guess_version(length, **kwargs) - return self._adopt_dispatched(obj) + 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_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, @@ -274,43 +276,8 @@ def _make_data(cls, data: 'Data_DTLS') -> 'dict[str, Any]': # type: ignore[over 'payload': cls._make_payload(data), } - def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'Any': - """Identify the concrete tier of the payload, and parse it as that tier. - - DTLS 1.0 and DTLS 1.2 are named directly by the record's legacy - version. DTLS 1.3's legacy-format records freeze theirs at DTLS - 1.2's value, and are told apart by peeking the - ``supported_versions`` extension (:meth:`SSL._tls_negotiates - `), falling - back to DTLS 1.2 -- the common case -- when that peek is - inconclusive. Any legacy version naming neither falls back to the - DTLS 1.2 tier too, so that its own :meth:`SSL._validate_tls_record - ` reports - the real reason the payload is not a genuine record. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data from whichever concrete tier was identified. - - """ - from pcapkit.protocols.application.dtlsv1_0 import DTLSv1_0 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.dtlsv1_2 import DTLSv1_2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.dtlsv1_3 import DTLSv1_3 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - - schema = self.__header__ - - protocol: '_Type[DTLS]' = DTLSv1_2 - if schema.version == _DTLSV1_0_VERSION: - protocol = DTLSv1_0 - elif schema.version == _DTLSV1_2_VERSION and self._tls_negotiates(schema, DTLS13_VERSION, dtls=True): - protocol = DTLSv1_3 - return protocol(self._data, length, **kwargs) - def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': # pylint: disable=unused-argument - """Read a DTLS record -- the real parse every concrete tier shares. + """Read a DTLS record, identifying its concrete tier as it goes. Structure of DTLS record layer, the ``DTLSPlaintext`` form [:rfc:`9147#section-4`] -- the full header this module reads; @@ -329,6 +296,20 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> opaque fragment[DTLSPlaintext.length]; } DTLSPlaintext; + Parses the record first, then identifies the concrete tier from its + legacy version and sets :attr:`alias` accordingly -- mirroring + :meth:`ARP.read `'s own + ``self._acnm = ...`` dispatch by operation code. DTLS 1.0 and DTLS + 1.2 are named directly by the record's legacy version. DTLS 1.3's + legacy-format records freeze theirs at DTLS 1.2's value, and are told + apart by peeking the ``supported_versions`` extension + (:meth:`SSL._tls_negotiates `), + falling back to DTLS 1.2 -- the common case -- when that peek is + inconclusive. Any legacy version naming neither never reaches this + dispatch at all: :meth:`SSL._validate_tls_record + `, called + first, already raises for it. + Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. @@ -359,6 +340,13 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> available = length if length is not None else len(self) self._validate_tls_record(schema, available=available, dtls=True) + if schema.version == _DTLSV1_0_VERSION: + self._alias = 'DTLS 1.0' + elif schema.version == _DTLSV1_2_VERSION and self._tls_negotiates(schema, DTLS13_VERSION, dtls=True): + self._alias = 'DTLS 1.3' + else: + self._alias = 'DTLS 1.2' + content = self._read_tls_content(schema.content_type, schema.fragment, dtls=True, epoch=schema.epoch) dtls = Data_DTLS( diff --git a/pcapkit/protocols/application/dtlsv1_0.py b/pcapkit/protocols/application/dtlsv1_0.py deleted file mode 100644 index 95226f79e5..0000000000 --- a/pcapkit/protocols/application/dtlsv1_0.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8 -*- -"""DTLSv1_0 - Datagram Transport Layer Security version 1.0 -=============================================================== - -.. module:: pcapkit.protocols.application.dtlsv1_0 - -:mod:`pcapkit.protocols.application.dtlsv1_0` contains -:class:`~pcapkit.protocols.application.dtlsv1_0.DTLSv1_0` only, the concrete -tier :class:`DTLS ` delegates to -for a record whose legacy version is ``0xFEFF`` [:rfc:`4347`]. DTLS 1.0 -shares the record-layer wire format every tier in the family does, so this -class contributes nothing of its own beyond a fixed :attr:`alias` and a -narrowed :meth:`id`: the actual parse is :meth:`DTLS._read_dtls_record -`, shared by -every tier in the family. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.dtls import DTLS as DTLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['DTLSv1_0'] - - -class DTLSv1_0(DTLSBase, data=Data_DTLS, schema=Schema_DTLS): - """This class implements Datagram Transport Layer Security version 1.0.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def alias(self) -> 'Literal["DTLS 1.0"]': - """Acronym of current protocol.""" - return 'DTLS 1.0' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_0"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('DTLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': - """Read Datagram Transport Layer Security version 1.0. - - Delegates to :meth:`DTLS._read_dtls_record - `, shared - by every tier in the DTLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_dtls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/dtlsv1_2.py b/pcapkit/protocols/application/dtlsv1_2.py deleted file mode 100644 index 8318654261..0000000000 --- a/pcapkit/protocols/application/dtlsv1_2.py +++ /dev/null @@ -1,83 +0,0 @@ -# -*- coding: utf-8 -*- -"""DTLSv1_2 - Datagram Transport Layer Security version 1.2 -=============================================================== - -.. module:: pcapkit.protocols.application.dtlsv1_2 - -:mod:`pcapkit.protocols.application.dtlsv1_2` contains -:class:`~pcapkit.protocols.application.dtlsv1_2.DTLSv1_2` only, the concrete -tier :class:`DTLS ` delegates to -for a record whose legacy version is ``0xFEFD`` [:rfc:`6347`] and that is -not negotiating DTLS 1.3 via a ``supported_versions`` extension. ``0xFEFD`` -is also the legacy version DTLS 1.3 freezes its own legacy-format records -at (:rfc:`9147#section-4.1`), so this tier doubles as the generic fallback -for that ambiguous value, and for any legacy version :class:`DTLS -`'s dispatch does not otherwise -recognise -- see :meth:`DTLS._guess_version -`. - -DTLS 1.2 shares the record-layer wire format every tier in the family does, -so this class contributes nothing of its own beyond a fixed :attr:`alias` -and a narrowed :meth:`id`: the actual parse is :meth:`DTLS._read_dtls_record -`, shared by -every tier in the family. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.dtls import DTLS as DTLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['DTLSv1_2'] - - -class DTLSv1_2(DTLSBase, data=Data_DTLS, schema=Schema_DTLS): - """This class implements Datagram Transport Layer Security version 1.2.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def alias(self) -> 'Literal["DTLS 1.2"]': - """Acronym of current protocol.""" - return 'DTLS 1.2' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_2"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('DTLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': - """Read Datagram Transport Layer Security version 1.2. - - Delegates to :meth:`DTLS._read_dtls_record - `, shared - by every tier in the DTLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_dtls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/dtlsv1_3.py b/pcapkit/protocols/application/dtlsv1_3.py deleted file mode 100644 index 72476b59dd..0000000000 --- a/pcapkit/protocols/application/dtlsv1_3.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -"""DTLSv1_3 - Datagram Transport Layer Security version 1.3 -=============================================================== - -.. module:: pcapkit.protocols.application.dtlsv1_3 - -:mod:`pcapkit.protocols.application.dtlsv1_3` contains -:class:`~pcapkit.protocols.application.dtlsv1_3.DTLSv1_3` only, the concrete -tier :class:`DTLS ` delegates to -for a record whose legacy version is ``0xFEFD`` [:rfc:`9147`] *and* whose -``ClientHello``/``ServerHello`` negotiates DTLS 1.3 via a -``supported_versions`` extension -- DTLS 1.3 freezes its own legacy-format -records at DTLS 1.2's value for backward compatibility -(:rfc:`9147#section-4.1`), so the record header alone cannot tell the two -apart; see :meth:`DTLS._guess_version -` and -:meth:`SSL._tls_negotiates ` -for how dispatch resolves it. - -**Only DTLS 1.3's legacy-format records are decoded here** -- the -``DTLSPlaintext`` shape every tier in the family shares, which DTLS 1.3 -keeps for the records exchanged before a connection has a negotiated epoch -(:rfc:`9147#section-4.1`). The *unified header* format :rfc:`9147#section-4` -introduces once epoch negotiation completes is not decoded by this class or -any other in this family (:issue:`2467`, which depends on this restructure -landing first): its first octet carries no content-type byte at all, so -:meth:`SSL._validate_tls_record -` rejects it and -it degrades to :class:`~pcapkit.protocols.misc.raw.Raw` rather than being -misdecoded as a legacy-format record. - -DTLS 1.3's legacy-format records share the record-layer wire format every -tier in the family does, so this class contributes nothing of its own -beyond a fixed :attr:`alias` and a narrowed :meth:`id`: the actual parse is -:meth:`DTLS._read_dtls_record -`, shared by -every tier in the family. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.dtls import DTLS as DTLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['DTLSv1_3'] - - -class DTLSv1_3(DTLSBase, data=Data_DTLS, schema=Schema_DTLS): - """This class implements Datagram Transport Layer Security version 1.3 - (legacy-format records only; see the module docstring). - - """ - - ########################################################################## - # Properties. - ########################################################################## - - @property - def alias(self) -> 'Literal["DTLS 1.3"]': - """Acronym of current protocol.""" - return 'DTLS 1.3' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["DTLS"], Literal["DTLSv1_3"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('DTLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': - """Read Datagram Transport Layer Security version 1.3 (legacy-format records). - - Delegates to :meth:`DTLS._read_dtls_record - `, shared - by every tier in the DTLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_dtls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/sslv3.py b/pcapkit/protocols/application/sslv3.py deleted file mode 100644 index e3e32e70ca..0000000000 --- a/pcapkit/protocols/application/sslv3.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -"""SSLv3 - Secure Sockets Layer version 3 -============================================ - -.. module:: pcapkit.protocols.application.sslv3 - -:mod:`pcapkit.protocols.application.sslv3` contains -:class:`~pcapkit.protocols.application.sslv3.SSLv3` only, the concrete tier -:class:`TLS ` delegates to for a -record whose legacy version is ``0x0300``. SSLv3 shares TLS's record-layer -wire format exactly -- the ``0x03xx`` legacy version range it and every TLS -version after it were designed to extend -- so this class contributes -nothing of its own beyond a fixed :attr:`alias` and a narrowed :meth:`id`: -the actual parse is :meth:`TLS._read_tls_record -`, shared by every -tier in the family. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.tls import TLS as TLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['SSLv3'] - - -class SSLv3(TLSBase, data=Data_TLS, schema=Schema_TLS): - """This class implements Secure Sockets Layer version 3.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def name(self) -> 'Literal["Secure Sockets Layer version 3"]': # type: ignore[override] - """Name of current protocol.""" - return 'Secure Sockets Layer version 3' - - @property - def alias(self) -> 'Literal["SSLv3"]': - """Acronym of current protocol.""" - return 'SSLv3' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["TLS"], Literal["SSLv3"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('TLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': - """Read Secure Sockets Layer version 3. - - Delegates to :meth:`TLS._read_tls_record - `, shared by - every tier in the TLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index 6b9b593090..2a6af0ef6e 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -5,16 +5,21 @@ .. module:: pcapkit.protocols.application.tls :mod:`pcapkit.protocols.application.tls` contains -:class:`~pcapkit.protocols.application.tls.TLS`, which is a version -dispatcher 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:`8446`) --- mirroring :class:`~pcapkit.protocols.application.http.HTTP`'s dispatch -over :mod:`~pcapkit.protocols.application.httpv1` and -:mod:`~pcapkit.protocols.application.httpv2`. :attr:`TCP.__proto__ -` binds this class at port -443; its own :meth:`read` identifies the concrete tier -- or, where the -payload is SSLv2-shaped instead, :class:`~pcapkit.protocols.application.ssl2.SSL2` --- and delegates to it, rather than parsing content itself. +: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:`8446`). :attr:`TCP.__proto__ +` binds :class:`TLS` at port +443; its own :meth:`~TLS.read` parses the (always wire-identical) record +header, then sets :attr:`~TLS.alias` (and, for SSLv3, :attr:`~TLS.name`) +according to the record's legacy version -- 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. Where the payload is SSLv2-shaped instead, +:meth:`~TLS.read` delegates to +:class:`~pcapkit.protocols.application.ssl2.SSL2`. Decoded Fields -------------- @@ -64,16 +69,13 @@ .. versionchanged:: 1.6.0 - :class:`TLS` is now a version dispatcher rather than a single generic - parser: it identifies SSLv3, TLS 1.0, TLS 1.1, TLS 1.2, or TLS 1.3 (and, - where available, SSLv2) and delegates to the matching concrete class -- - :mod:`~pcapkit.protocols.application.sslv3`, - :mod:`~pcapkit.protocols.application.tlsv1_0`, - :mod:`~pcapkit.protocols.application.tlsv1_1`, - :mod:`~pcapkit.protocols.application.tlsv1_2`, or - :mod:`~pcapkit.protocols.application.tlsv1_3` -- the same shape - :class:`~pcapkit.protocols.application.http.HTTP` already uses for its own - family (:issue:`2437`). + :class:`TLS` identifies SSLv3, TLS 1.0, TLS 1.1, TLS 1.2, or TLS 1.3 (and, + where available, SSLv2) and reports the identified tier through + :attr:`~TLS.alias` -- :class:`self ` throughout, never a distinct + per-version instance -- the same shape + :class:`~pcapkit.protocols.link.arp.ARP` already uses for its own family + (:issue:`2437`). :class:`SSLv3` remains available for explicit, manual + construction only; :meth:`TLS.read` never constructs one itself. """ from typing import TYPE_CHECKING, cast @@ -94,7 +96,7 @@ from pcapkit.protocols.protocol import ProtocolBase from pcapkit.protocols.schema.schema import Schema -__all__ = ['TLS'] +__all__ = ['TLS', 'SSLv3'] #: Legacy record-version major octet TLS/SSLv3+ records carry (``0x03xx``, #: SSLv3 through the TLS 1.3 wire value). Mirrors @@ -162,7 +164,13 @@ def _looks_like_sslv2(data: 'bytes') -> 'bool': class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): - """This class implements Transport Layer Security. + """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 @@ -173,24 +181,30 @@ class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): """ + #: Name of corresponding protocol -- dynamic only for SSLv3, the one + #: tier in this family with a genuinely different full name; every other + #: tier keeps this default. Mirrors + #: :attr:`ARP._name `. + _name = 'Transport Layer Security' # type: Literal["Transport Layer Security", "Secure Sockets Layer version 3"] # pylint: disable=line-too-long + #: Resolved alias once :meth:`read` has identified the concrete tier -- - #: e.g. ``'SSLv3'`` or ``'TLS 1.2'`` -- mirroring - #: :attr:`HTTP._version `. - #: The class name itself until then, same as the unversioned default - #: every :class:`~pcapkit.protocols.protocol.ProtocolBase` falls back to. - _alias = 'TLS' + #: e.g. ``'SSLv3'`` or ``'TLS 1.2'``. The class name itself until then, + #: same as the unversioned default every + #: :class:`~pcapkit.protocols.protocol.ProtocolBase` falls back to. + #: Mirrors :attr:`ARP._acnm `. + _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"]': + def name(self) -> 'Literal["Transport Layer Security", "Secure Sockets Layer version 3"]': """Name of current protocol.""" - return 'Transport Layer Security' + return self._name @property - def alias(self) -> 'str': + 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 @@ -208,54 +222,67 @@ def length(self) -> 'Literal[5]': ########################################################################## @classmethod - def id(cls) -> 'tuple[Literal["TLS"], Literal["SSLv3"], Literal["TLSv1_0"], Literal["TLSv1_1"], Literal["TLSv1_2"], Literal["TLSv1_3"]]': # type: ignore[override] # pylint: disable=line-too-long + def id(cls) -> 'tuple[Literal["TLS"]]': # type: ignore[override] """Index ID of the protocol. Returns: - Index ID of the protocol -- the canonical name first, then every - concrete tier this dispatcher may delegate to. SSLv2 is - deliberately not among them: :meth:`read` may still delegate to - it when the payload is shaped that way (see - :func:`_looks_like_sslv2`), but this tuple is checked against - *every* instance of this class regardless of which tier it - actually resolved to, and genuine TLS 1.2 traffic must not match - an ``SSL2`` lookup just because *some* port-443 payload could - have been SSLv2. Inspect :attr:`alias`, or the type of - :attr:`info `, - to tell SSLv2 traffic apart instead. + Index ID of the protocol. Fixed, regardless of which tier + :meth:`read` identifies the record as: a dynamically dispatched + record never becomes a different *class*, only a different + :attr:`alias`, so a genuine TLS 1.2 record must not stop + matching a lookup keyed on this value just because *some* + instance of this class happened to parse as, say, SSLv3. + Inspect :attr:`alias`, or the type of :attr:`info + `, to tell the + identified tier apart instead. SSLv2 is not among them either: + :meth:`read` may still delegate to it when the payload is shaped + that way (see :func:`_looks_like_sslv2`), but doing so hands back + an :class:`~pcapkit.protocols.application.ssl2.SSL2` instance, + which carries its own, distinct :meth:`id`. """ - return ('TLS', 'SSLv3', 'TLSv1_0', 'TLSv1_1', 'TLSv1_2', 'TLSv1_3') + return ('TLS',) def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': """Read Transport Layer Security (TLS), or whatever port 443 actually carries. - Identifies the concrete tier -- SSLv2, SSLv3, or one of TLS 1.0 - through 1.3 -- and delegates to it, mirroring - :meth:`HTTP.read `'s - own dispatch over its family. + Where the payload is SSLv2-shaped instead (:func:`_looks_like_sslv2`), + delegates to :class:`~pcapkit.protocols.application.ssl2.SSL2` and + adopts its parse. Otherwise parses the TLS/SSLv3+ record itself via + :meth:`_read_tls_record`, which also sets :attr:`alias` (and, for + SSLv3, :attr:`name`) according to the record's legacy version -- + :data:`self ` throughout, never a distinct instance. Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. Returns: - Parsed packet data, from whichever concrete tier :meth:`_guess_version` - identified. + Parsed packet data. Raises: - ProtocolError: If the identified tier's own parse fails -- see - :meth:`SSLv3._read_tls_record ` - (shared by every TLS-family tier) or - :class:`~pcapkit.protocols.application.ssl2.SSL2`'s own - :meth:`read `. + ProtocolError: If the record cannot be a genuine TLS/SSLv3+ + record -- see :meth:`_read_tls_record` -- or, where + delegated, if :class:`~pcapkit.protocols.application.ssl2.SSL2`'s + own :meth:`read ` + fails. """ if length is None: length = len(self) - obj = self._guess_version(length, **kwargs) - return self._adopt_dispatched(obj) + if _looks_like_sslv2(self._data[:length]): + try: + from pcapkit.protocols.application.ssl2 import SSL2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + except ImportError: + pass # SSLv2 support (#2457) is not present on this + # checkout -- fall through to the TLS/SSLv3+ parse + # below, exactly as if this identification did not exist. + else: + obj = SSL2(self._data, length, **kwargs) + return self._adopt_dispatched(obj) + + 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 @@ -342,68 +369,8 @@ def _make_data(cls, data: 'Data_TLS') -> 'dict[str, Any]': # type: ignore[overr 'payload': cls._make_payload(data), } - def _guess_version(self, length: 'int', **kwargs: 'Any') -> 'Any': - """Identify the concrete tier of the payload, and parse it as that tier. - - Positive identification first, same philosophy as - :meth:`HTTP._guess_version `: - SSLv2 is told apart from the modern family by its wire-incompatible - header shape (:func:`_looks_like_sslv2`), not by trial-parsing. Within - the modern family, SSLv3/TLS 1.0/TLS 1.1 are named directly by the - record's legacy version; TLS 1.2 and TLS 1.3 share the same legacy - value (``0x0303``) and are told apart by peeking the - ``supported_versions`` extension (:meth:`SSL._tls_negotiates - `), falling - back to TLS 1.2 -- the common case -- when that peek is inconclusive. - Any legacy version outside SSLv3+'s ``0x03xx`` range also falls back - to the TLS 1.2 tier, so that its own :meth:`SSL._validate_tls_record - ` reports - the real reason the payload is not a genuine record, rather than this - method guessing at one. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data from whichever concrete tier was identified. - - """ - schema = self.__header__ - - if _looks_like_sslv2(self._data[:length]): - try: - from pcapkit.protocols.application.ssl2 import SSL2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - except ImportError: - pass # SSLv2 support (#2457) is not present on this - # checkout -- fall through to the TLS-family dispatch - # below, exactly as if this identification did not exist. - else: - return SSL2(self._data, length, **kwargs) - - from pcapkit.protocols.application.sslv3 import SSLv3 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.tlsv1_0 import TLSv1_0 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.tlsv1_1 import TLSv1_1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.tlsv1_2 import TLSv1_2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - from pcapkit.protocols.application.tlsv1_3 import TLSv1_3 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - - major = schema.version >> 8 - minor = schema.version & 0xFF - - protocol: '_Type[TLS]' = TLSv1_2 - if major == _TLS_VERSION_MAJOR: - if not minor: - protocol = SSLv3 - elif minor == 0x01: - protocol = TLSv1_0 - elif minor == 0x02: - protocol = TLSv1_1 - elif minor == 0x03 and self._tls_negotiates(schema, TLS13_VERSION, dtls=False): - protocol = TLSv1_3 - return protocol(self._data, length, **kwargs) - def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': # pylint: disable=unused-argument - """Read a TLS/SSLv3+ record -- the real parse every concrete tier shares. + """Read a TLS/SSLv3+ record, identifying its concrete tier as it goes. Structure of TLS record layer [:rfc:`9846#section-5.1`]: @@ -416,6 +383,20 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' opaque fragment[TLSPlaintext.length]; } TLSPlaintext; + Parses the record first, then identifies the concrete tier from its + legacy version and sets :attr:`alias` (and, for SSLv3, :attr:`name`) + accordingly -- mirroring :meth:`ARP.read `'s + own ``self._acnm = ...`` dispatch by operation code. SSLv3, TLS 1.0, + and TLS 1.1 are named directly by the record's legacy version; TLS + 1.2 and TLS 1.3 share the same legacy value (``0x0303``) and are told + apart by peeking the ``supported_versions`` extension + (:meth:`SSL._tls_negotiates `), + falling back to TLS 1.2 -- the common case -- when that peek is + inconclusive. Any legacy version outside SSLv3+'s ``0x03xx`` range + never reaches this dispatch at all: :meth:`SSL._validate_tls_record + `, called + first, already raises for it. + Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. @@ -435,6 +416,23 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' available = length if length is not None else len(self) self._validate_tls_record(schema, available=available, dtls=False) + minor = schema.version & 0xFF + if not minor: + self._alias = 'SSLv3' + self._name = 'Secure Sockets Layer version 3' + elif minor == 0x01: + self._alias = 'TLS 1.0' + self._name = 'Transport Layer Security' + elif minor == 0x02: + self._alias = 'TLS 1.1' + self._name = 'Transport Layer Security' + elif minor == 0x03 and self._tls_negotiates(schema, TLS13_VERSION, dtls=False): + self._alias = 'TLS 1.3' + self._name = 'Transport Layer Security' + else: + self._alias = 'TLS 1.2' + self._name = 'Transport Layer Security' + content = self._read_tls_content(schema.content_type, schema.fragment, dtls=False) tls = Data_TLS( content_type=schema.content_type, @@ -445,3 +443,31 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' remaining = available - (self.length + schema.length) return self._decode_next_layer(tls, -1, remaining) + + +class SSLv3(TLS, schema=Schema_TLS, data=Data_TLS): + """This class implements Secure Sockets Layer version 3, for explicit, + manual construction only. + + :meth:`TLS.read `'s own dispatch never constructs an instance + of this class -- a record identified as SSLv3 stays a :class:`TLS` + instance throughout, with :attr:`~TLS.alias` and :attr:`~TLS.name` set + accordingly. This class exists for a caller who already knows they want + to build or represent something as specifically SSLv3, mirroring + :class:`~pcapkit.protocols.link.arp.InARP`'s relationship to + :class:`~pcapkit.protocols.link.arp.ARP`: it narrows :meth:`id` only, and + inherits :meth:`~TLS.read` unparsed -- parsing SSLv3-shaped data through + this class sets :attr:`~TLS.alias` to ``'SSLv3'`` the same way parsing it + through :class:`TLS` would, since the wire format and the dispatch logic + are both :class:`TLS`'s. + + """ + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["SSLv3"]]': # type: ignore[override] + """Index ID of the protocol.""" + return ('SSLv3',) diff --git a/pcapkit/protocols/application/tlsv1_0.py b/pcapkit/protocols/application/tlsv1_0.py deleted file mode 100644 index 89b163876f..0000000000 --- a/pcapkit/protocols/application/tlsv1_0.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8 -*- -"""TLSv1_0 - Transport Layer Security version 1.0 -===================================================== - -.. module:: pcapkit.protocols.application.tlsv1_0 - -:mod:`pcapkit.protocols.application.tlsv1_0` contains -:class:`~pcapkit.protocols.application.tlsv1_0.TLSv1_0` only, the concrete -tier :class:`TLS ` delegates to for a -record whose legacy version is ``0x0301`` [:rfc:`2246`]. TLS 1.0 shares the -record-layer wire format every tier in the family does, so this class -contributes nothing of its own beyond a fixed :attr:`alias` and a narrowed -:meth:`id`: the actual parse is :meth:`TLS._read_tls_record -`, shared by every -tier in the family. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.tls import TLS as TLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['TLSv1_0'] - - -class TLSv1_0(TLSBase, data=Data_TLS, schema=Schema_TLS): - """This class implements Transport Layer Security version 1.0.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def alias(self) -> 'Literal["TLS 1.0"]': - """Acronym of current protocol.""" - return 'TLS 1.0' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_0"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('TLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': - """Read Transport Layer Security version 1.0. - - Delegates to :meth:`TLS._read_tls_record - `, shared by - every tier in the TLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tlsv1_1.py b/pcapkit/protocols/application/tlsv1_1.py deleted file mode 100644 index e46b4bf555..0000000000 --- a/pcapkit/protocols/application/tlsv1_1.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8 -*- -"""TLSv1_1 - Transport Layer Security version 1.1 -===================================================== - -.. module:: pcapkit.protocols.application.tlsv1_1 - -:mod:`pcapkit.protocols.application.tlsv1_1` contains -:class:`~pcapkit.protocols.application.tlsv1_1.TLSv1_1` only, the concrete -tier :class:`TLS ` delegates to for a -record whose legacy version is ``0x0302`` [:rfc:`4346`]. TLS 1.1 shares the -record-layer wire format every tier in the family does, so this class -contributes nothing of its own beyond a fixed :attr:`alias` and a narrowed -:meth:`id`: the actual parse is :meth:`TLS._read_tls_record -`, shared by every -tier in the family. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.tls import TLS as TLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['TLSv1_1'] - - -class TLSv1_1(TLSBase, data=Data_TLS, schema=Schema_TLS): - """This class implements Transport Layer Security version 1.1.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def alias(self) -> 'Literal["TLS 1.1"]': - """Acronym of current protocol.""" - return 'TLS 1.1' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_1"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('TLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': - """Read Transport Layer Security version 1.1. - - Delegates to :meth:`TLS._read_tls_record - `, shared by - every tier in the TLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tlsv1_2.py b/pcapkit/protocols/application/tlsv1_2.py deleted file mode 100644 index be7277c629..0000000000 --- a/pcapkit/protocols/application/tlsv1_2.py +++ /dev/null @@ -1,83 +0,0 @@ -# -*- coding: utf-8 -*- -"""TLSv1_2 - Transport Layer Security version 1.2 -===================================================== - -.. module:: pcapkit.protocols.application.tlsv1_2 - -:mod:`pcapkit.protocols.application.tlsv1_2` contains -:class:`~pcapkit.protocols.application.tlsv1_2.TLSv1_2` only, the concrete -tier :class:`TLS ` delegates to for a -record whose legacy version is ``0x0303`` [:rfc:`5246`] and that is not -negotiating TLS 1.3 via a ``supported_versions`` extension. ``0x0303`` is -also the legacy version TLS 1.3 freezes its own records at -(:rfc:`9846#section-4.1.3`), so this tier doubles as the generic fallback for -that ambiguous value, and for any legacy version :class:`TLS -`'s dispatch does not otherwise -recognise -- see :meth:`TLS._guess_version -`. - -TLS 1.2 shares the record-layer wire format every tier in the family does, -so this class contributes nothing of its own beyond a fixed :attr:`alias` -and a narrowed :meth:`id`: the actual parse is :meth:`TLS._read_tls_record -`, shared by every -tier in the family. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.tls import TLS as TLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['TLSv1_2'] - - -class TLSv1_2(TLSBase, data=Data_TLS, schema=Schema_TLS): - """This class implements Transport Layer Security version 1.2.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def alias(self) -> 'Literal["TLS 1.2"]': - """Acronym of current protocol.""" - return 'TLS 1.2' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_2"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('TLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': - """Read Transport Layer Security version 1.2. - - Delegates to :meth:`TLS._read_tls_record - `, shared by - every tier in the TLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/application/tlsv1_3.py b/pcapkit/protocols/application/tlsv1_3.py deleted file mode 100644 index 26f2bab07d..0000000000 --- a/pcapkit/protocols/application/tlsv1_3.py +++ /dev/null @@ -1,89 +0,0 @@ -# -*- coding: utf-8 -*- -"""TLSv1_3 - Transport Layer Security version 1.3 -===================================================== - -.. module:: pcapkit.protocols.application.tlsv1_3 - -:mod:`pcapkit.protocols.application.tlsv1_3` contains -:class:`~pcapkit.protocols.application.tlsv1_3.TLSv1_3` only, the concrete -tier :class:`TLS ` delegates to for a -record whose legacy version is ``0x0303`` [:rfc:`9846`] *and* whose -``ClientHello``/``ServerHello`` negotiates TLS 1.3 via a -``supported_versions`` extension -- TLS 1.3 freezes its own record-layer -legacy version at TLS 1.2's value for backward compatibility -(:rfc:`9846#section-4.1.3`), so the record header alone cannot tell the two -apart; see :meth:`TLS._guess_version -` and -:meth:`SSL._tls_negotiates ` -for how dispatch resolves it. - -TLS 1.3 shares the record-layer wire format every tier in the family does, -so this class contributes nothing of its own beyond a fixed :attr:`alias` -and a narrowed :meth:`id`: the actual parse is :meth:`TLS._read_tls_record -`, shared by every -tier in the family. This dissector's scope stops at message-TYPE-level -framing (:issue:`2437`), so TLS 1.3-specific content -- an encrypted -``EncryptedExtensions``, ``Certificate``, or ``Finished``, all of which this -version sends under ``application_data`` rather than their TLS 1.2 content -types -- stays the same opaque :obj:`bytes` every other version's encrypted -content does; nothing in this class decodes differently for being 1.3. - -.. versionadded:: 1.6.0 - -""" -from typing import TYPE_CHECKING - -from pcapkit.protocols.application.tls import TLS as TLSBase -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 typing import Any, Optional - - from typing_extensions import Literal - -__all__ = ['TLSv1_3'] - - -class TLSv1_3(TLSBase, data=Data_TLS, schema=Schema_TLS): - """This class implements Transport Layer Security version 1.3.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def alias(self) -> 'Literal["TLS 1.3"]': - """Acronym of current protocol.""" - return 'TLS 1.3' - - ########################################################################## - # Methods. - ########################################################################## - - @classmethod - def id(cls) -> 'tuple[Literal["TLS"], Literal["TLSv1_3"]]': # type: ignore[override] - """Index ID of the protocol. - - Returns: - Index ID of the protocol. - - """ - return ('TLS', cls.__name__) # type: ignore[return-value] - - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': - """Read Transport Layer Security version 1.3. - - Delegates to :meth:`TLS._read_tls_record - `, shared by - every tier in the TLS family. - - Args: - length: Length of packet data. - **kwargs: Arbitrary keyword arguments. - - Returns: - Parsed packet data. - - """ - return self._read_tls_record(length, **kwargs) diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index 2a11735765..478960b838 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -639,10 +639,15 @@ def _dtls_client_hello_wire(*, supported_versions: 'bytes | None' = None) -> byt class VersionDispatchTests(unittest.TestCase): - """:meth:`TLS._guess_version`/:meth:`DTLS._guess_version` -- the restructure - :issue:`2437`'s follow-up asked for: a thin per-version dispatcher over - the family, mirroring :class:`~pcapkit.protocols.application.http.HTTP`'s - own dispatch over HTTP/1.* and HTTP/2. + """:meth:`TLS.read`/:meth:`DTLS.read` -- the restructure :issue:`2437`'s + follow-up asked for: 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. """ @@ -711,41 +716,78 @@ def test_dtls_0xfefd_negotiating_1_3_dispatches_dtls_1_3(self) -> None: parsed = DTLS(wire, len(wire)) self.assertEqual(parsed.alias, 'DTLS 1.3') - def test_concrete_tiers_are_constructible_directly_and_narrow_id(self) -> None: - """Each concrete tier works standalone, bypassing dispatch entirely.""" - from pcapkit.protocols.application.dtlsv1_0 import DTLSv1_0 - from pcapkit.protocols.application.dtlsv1_2 import DTLSv1_2 - from pcapkit.protocols.application.dtlsv1_3 import DTLSv1_3 - from pcapkit.protocols.application.sslv3 import SSLv3 - from pcapkit.protocols.application.tlsv1_0 import TLSv1_0 - from pcapkit.protocols.application.tlsv1_1 import TLSv1_1 - from pcapkit.protocols.application.tlsv1_2 import TLSv1_2 - from pcapkit.protocols.application.tlsv1_3 import TLSv1_3 - - tls_wire = b'\x17\x03\x03\x00\x05hello' - for cls, alias in ((SSLv3, 'SSLv3'), (TLSv1_0, 'TLS 1.0'), (TLSv1_1, 'TLS 1.1'), - (TLSv1_2, 'TLS 1.2'), (TLSv1_3, 'TLS 1.3')): - with self.subTest(cls=cls.__name__): - parsed = cls(tls_wire, len(tls_wire)) - self.assertEqual(parsed.alias, alias) - self.assertEqual(parsed.info.content, b'hello') - self.assertEqual(cls.id(), ('TLS', cls.__name__)) + 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. - dtls_wire = (b'\x17\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') - + (5).to_bytes(2, 'big') + b'hello') - for cls, alias in ((DTLSv1_0, 'DTLS 1.0'), (DTLSv1_2, 'DTLS 1.2'), (DTLSv1_3, 'DTLS 1.3')): - with self.subTest(cls=cls.__name__): - parsed = cls(dtls_wire, len(dtls_wire)) - self.assertEqual(parsed.alias, alias) - self.assertEqual(parsed.info.content, b'hello') - self.assertEqual(cls.id(), ('DTLS', cls.__name__)) + """ + 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_dispatcher_id_names_every_concrete_tier_canonical_first(self) -> None: + def test_tls_and_dtls_id_are_fixed_regardless_of_dispatched_tier(self) -> None: + """:meth:`TLS.id`/:meth:`DTLS.id` no longer 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', 'SSLv3', 'TLSv1_0', 'TLSv1_1', 'TLSv1_2', 'TLSv1_3')) - self.assertEqual(DTLS.id(), ('DTLS', 'DTLSv1_0', 'DTLSv1_2', 'DTLSv1_3')) + 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. @@ -774,8 +816,8 @@ def test_malformed_client_hello_raises_through_dispatch_same_as_before(self) -> class SSLv2HeuristicTests(unittest.TestCase): """:func:`pcapkit.protocols.application.tls._looks_like_sslv2` -- the - disambiguation :meth:`TLS._guess_version - ` uses to route + disambiguation :meth:`TLS.read + ` uses to route port-443 traffic to :class:`~pcapkit.protocols.application.ssl2.SSL2` rather than the TLS family. From 89658c97215384d5d53340871896c0b4f31a4a36 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 15:03:30 -0400 Subject: [PATCH 07/11] fix(application): export the SSL family and stop branding unknown versions - Export SSL, SSLv3, TLS and DTLS from pcapkit.protocols.application, pcapkit.protocols (and so __proto__), pcapkit.all and pcapkit, the way NGAP is; star-import pcapkit.const.tls and add its const docs page. - Brand a record version outside the known table as plain TLS/DTLS instead of falling through to TLS 1.2/DTLS 1.2, and run the TLS 1.3 supported_versions peek at 0x0301/0xFEFF too, which an initial ClientHello may carry (RFC 9846 section 5.1, RFC 9147 section 4.1). - SSL._adopt_dispatched now adopts the delegate's name as well as its alias. - Docs: SSL is the umbrella over the SSL versions, TLS and DTLS; tls.rst covers SSLv3 to TLS 1.3 and the dynamic alias/name, dtls.rst DTLS 1.0 to 1.3; bare issue-only versionadded bodies dropped. Tests: new dispatch, delegate and export cases fail on 077f9e010 and pass here. --- docs/source/pcapkit/const/index.rst | 1 + docs/source/pcapkit/const/tls.rst | 53 ++++++ .../pcapkit/protocols/application/dtls.rst | 44 ++++- .../pcapkit/protocols/application/ssl.rst | 16 +- .../pcapkit/protocols/application/tls.rst | 63 ++++++- pcapkit/__init__.py | 2 +- pcapkit/all.py | 1 + pcapkit/const/__init__.py | 3 + pcapkit/const/tls/__init__.py | 10 +- pcapkit/protocols/__init__.py | 1 + pcapkit/protocols/application/__init__.py | 6 +- pcapkit/protocols/application/dtls.py | 35 ++-- pcapkit/protocols/application/ssl.py | 30 +-- pcapkit/protocols/application/tls.py | 65 ++++--- .../application/test_ssl_tls_dtls_unit.py | 175 +++++++++++++++++- 15 files changed, 432 insertions(+), 73 deletions(-) create mode 100644 docs/source/pcapkit/const/tls.rst 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 index e97bcc6a8c..c7b5e99c32 100644 --- a/docs/source/pcapkit/protocols/application/dtls.rst +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -5,11 +5,40 @@ DTLS - Datagram Transport Layer Security :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.2 -(:rfc:`6347`) and DTLS 1.3 (: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. +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.1`), 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 Limitation ----------------- @@ -32,8 +61,6 @@ never fragments. .. versionadded:: 1.6.0 - :issue:`2437`. - .. raw:: html
@@ -43,8 +70,11 @@ never fragments. :show-inheritance: .. autoproperty:: name + .. autoproperty:: alias .. autoproperty:: length + .. automethod:: id + .. automethod:: read .. automethod:: make diff --git a/docs/source/pcapkit/protocols/application/ssl.rst b/docs/source/pcapkit/protocols/application/ssl.rst index 43ab59ddfe..663a3b62bf 100644 --- a/docs/source/pcapkit/protocols/application/ssl.rst +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -5,10 +5,16 @@ SSL - Secure Sockets Layer :mod:`pcapkit.protocols.application.ssl` contains :class:`~pcapkit.protocols.application.ssl.SSL` only, -which is a base class for Transport Layer Security (TLS) protocol -family [*]_, e.g. -:class:`~pcapkit.protocols.application.tls.TLS` and -:class:`~pcapkit.protocols.application.dtls.DTLS`. +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 record-layer header -- a :class:`~pcapkit.const.tls.content_type.ContentType`, a legacy record version, and a length -- is byte-identical in shape between @@ -22,8 +28,6 @@ accepting bytes that cannot be a genuine record. .. versionadded:: 1.6.0 - :issue:`2437`. - .. raw:: html
diff --git a/docs/source/pcapkit/protocols/application/tls.rst b/docs/source/pcapkit/protocols/application/tls.rst index 44347e04fd..ddd67f88f1 100644 --- a/docs/source/pcapkit/protocols/application/tls.rst +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -4,11 +4,63 @@ TLS/SSL - Transport Layer Security .. module:: pcapkit.protocols.application.tls :mod:`pcapkit.protocols.application.tls` contains -:class:`~pcapkit.protocols.application.tls.TLS` only, which implements an -extractor for Transport Layer Security (TLS) [*]_ -- TLS 1.2 (:rfc:`5246`) and -TLS 1.3 (:rfc:`9846`, which obsoletes :rfc:`8446`) -- at the record layer, plus +: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:`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`` + * - ``0x0303``, or ``0x0301`` on an initial ``ClientHello``, 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. + +: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 -------------- @@ -55,8 +107,6 @@ for why rejecting such a fragment is the honest answer at this layer. .. versionadded:: 1.6.0 - :issue:`2437`. - .. raw:: html
@@ -66,8 +116,11 @@ for why rejecting such a fragment is the honest answer at this layer. :show-inheritance: .. autoproperty:: name + .. autoproperty:: alias .. autoproperty:: length + .. automethod:: id + .. automethod:: read .. automethod:: make 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 index 91861f5991..05891fccd9 100644 --- a/pcapkit/const/tls/__init__.py +++ b/pcapkit/const/tls/__init__.py @@ -5,11 +5,11 @@ .. module:: pcapkit.const.tls -This module contains all constant enumerations of -:class:`~pcapkit.protocols.application.ssl.SSL` implementations -- i.e. -:class:`~pcapkit.protocols.application.tls.TLS` and -:class:`~pcapkit.protocols.application.dtls.DTLS`. Available enumerations -include: +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:: 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/__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 index aa86041926..907390b783 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -90,6 +90,9 @@ #: Legacy record version DTLS 1.2 carries, and the value DTLS 1.3 freezes its #: own legacy-format records at (:rfc:`9147#section-4.1`). _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.1`). +_DTLS13_LEGACY_VERSIONS = frozenset({_DTLSV1_0_VERSION, _DTLSV1_2_VERSION}) class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): @@ -299,13 +302,15 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> Parses the record first, then identifies the concrete tier from its legacy version and sets :attr:`alias` accordingly -- mirroring :meth:`ARP.read `'s own - ``self._acnm = ...`` dispatch by operation code. DTLS 1.0 and DTLS - 1.2 are named directly by the record's legacy version. DTLS 1.3's - legacy-format records freeze theirs at DTLS 1.2's value, and are told - apart by peeking the ``supported_versions`` extension - (:meth:`SSL._tls_negotiates `), - falling back to DTLS 1.2 -- the common case -- when that peek is - inconclusive. Any legacy version naming neither never reaches this + ``self._acnm = ...`` dispatch by operation code. DTLS 1.3's + legacy-format records freeze theirs at DTLS 1.2's value -- or DTLS + 1.0's on an initial ``ClientHello`` (:rfc:`9147#section-4.1`) -- and + are told apart by peeking the ``supported_versions`` extension + (:meth:`SSL._tls_negotiates `). + Otherwise DTLS 1.0 (``0xFEFF``) and DTLS 1.2 (``0xFEFD``) are named + directly by the record's legacy version, and any other ``0xFExx`` + value keeps the generic ``'DTLS'`` alias rather than claiming a tier. + A legacy version outside ``0xFExx`` never reaches this dispatch at all: :meth:`SSL._validate_tls_record `, called first, already raises for it. @@ -340,12 +345,20 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> available = length if length is not None else len(self) self._validate_tls_record(schema, available=available, dtls=True) - if schema.version == _DTLSV1_0_VERSION: - self._alias = 'DTLS 1.0' - elif schema.version == _DTLSV1_2_VERSION and self._tls_negotiates(schema, DTLS13_VERSION, dtls=True): + # NOTE: DTLS 1.3 is checked first, and at DTLS 1.0's value as well as + # DTLS 1.2's: an initial ClientHello may carry {254, 255} "for + # compatibility purposes" (RFC 9147, section 4.1). Any other legacy + # version -- DTLS 1.3's own 0xFEFC included, which never appears on + # the record layer -- names no known tier and keeps the generic + # ``DTLS`` alias rather than claiming a version it does not carry. + if schema.version in _DTLS13_LEGACY_VERSIONS and self._tls_negotiates(schema, DTLS13_VERSION, dtls=True): self._alias = 'DTLS 1.3' - else: + 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) diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index 005ba5919c..0c6b9e9448 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -6,10 +6,11 @@ :mod:`pcapkit.protocols.application.ssl` contains :class:`~pcapkit.protocols.application.ssl.SSL` only, -which is a base class for Transport Layer Security (TLS) protocol -family [*]_, e.g. -:class:`~pcapkit.protocols.application.tls.TLS` and -:class:`~pcapkit.protocols.application.dtls.DTLS`. +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 record-layer header -- a :class:`~pcapkit.const.tls.content_type.ContentType`, a legacy record version, and a length -- is byte-identical in shape between @@ -220,10 +221,12 @@ def _tls_handshake_tiles(fragment: 'bytes') -> 'bool': class SSL(Application[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-method - """Base class for the TLS protocol family. + """Umbrella base class for the SSL protocol family: the SSL versions, TLS and DTLS. - - Transport Layer Security (:class:`~pcapkit.protocols.application.tls.TLS`) [:rfc:`5246`, :rfc:`9846`] - - Datagram TLS (:class:`~pcapkit.protocols.application.dtls.DTLS`) [:rfc:`6347`, :rfc:`9147`] + - 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`] """ @@ -361,13 +364,15 @@ def _tls_negotiates(self, schema: 'Any', target: 'int', *, dtls: 'bool') -> 'boo def _adopt_dispatched(self, obj: 'Any') -> 'Any': """Adopt a dispatched tier's parse result as this dispatcher's own. - Shared by every family's version-dispatching ``read()`` -- - :meth:`TLS.read ` and - :meth:`DTLS.read ` -- - mirroring :meth:`HTTP.read `'s + Used where a version-dispatching ``read()`` hands the payload to a + different class -- :meth:`TLS.read ` + delegating an SSLv2-shaped payload -- mirroring + :meth:`HTTP.read `'s own adoption of whichever versioned object it constructed: the dispatcher's own :attr:`alias ` - becomes the identified tier's, and a next layer the tier's own parse + and :attr:`name ` both + become the delegate's -- the same pair ``read()`` sets when it parses + the record itself -- and a next layer the delegate's own parse already decoded is carried over rather than decoded twice. Args: @@ -379,6 +384,7 @@ def _adopt_dispatched(self, obj: 'Any') -> 'Any': """ self._alias = obj.alias # pylint: disable=attribute-defined-outside-init + self._name = obj.name # pylint: disable=attribute-defined-outside-init next_ = getattr(obj, '_next', None) if next_ is not None and not isinstance(next_, NoPayload): self._next = next_ # pylint: disable=attribute-defined-outside-init diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index 2a6af0ef6e..0e9a33151b 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -106,6 +106,26 @@ #: is the same one by definition. _TLS_VERSION_MAJOR = 0x03 +#: Legacy record version SSLv3 carries -- the one tier in this family with a +#: different full :attr:`~TLS.name` as well as a different :attr:`~TLS.alias`. +_SSLV3_VERSION = 0x0300 + +#: Legacy record version to alias, for every tier the record layer's own +#: version names directly. ``0x0303`` reads as TLS 1.2 unless the +#: ``supported_versions`` peek finds TLS 1.3 (see +#: :data:`_TLS13_LEGACY_VERSIONS`). A version not listed here keeps the +#: generic ``'TLS'`` alias. +_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}) + #: The content types IANA's registry assigns that a genuine *first* record on #: a fresh connection carries on the wire -- the four #: :func:`_looks_like_sslv2` tests the second octet of, to tell a real @@ -386,13 +406,16 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' Parses the record first, then identifies the concrete tier from its legacy version and sets :attr:`alias` (and, for SSLv3, :attr:`name`) accordingly -- mirroring :meth:`ARP.read `'s - own ``self._acnm = ...`` dispatch by operation code. SSLv3, TLS 1.0, - and TLS 1.1 are named directly by the record's legacy version; TLS - 1.2 and TLS 1.3 share the same legacy value (``0x0303``) and are told - apart by peeking the ``supported_versions`` extension - (:meth:`SSL._tls_negotiates `), - falling back to TLS 1.2 -- the common case -- when that peek is - inconclusive. Any legacy version outside SSLv3+'s ``0x03xx`` range + own ``self._acnm = ...`` dispatch by operation code. TLS 1.3 records + carry a legacy version of ``0x0303`` -- or ``0x0301`` on an initial + ``ClientHello`` (:rfc:`9846#section-5.1`) -- and are told apart by + peeking the ``supported_versions`` extension + (:meth:`SSL._tls_negotiates `). + Otherwise SSLv3 (``0x0300``), TLS 1.0 (``0x0301``), TLS 1.1 + (``0x0302``) and TLS 1.2 (``0x0303``) are named directly by the + record's legacy version. Any other ``0x03xx`` value names no known + tier and keeps the generic ``'TLS'`` alias rather than claiming one. + Any legacy version outside SSLv3+'s ``0x03xx`` range never reaches this dispatch at all: :meth:`SSL._validate_tls_record `, called first, already raises for it. @@ -416,22 +439,22 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' available = length if length is not None else len(self) self._validate_tls_record(schema, available=available, dtls=False) - minor = schema.version & 0xFF - if not minor: - self._alias = 'SSLv3' - self._name = 'Secure Sockets Layer version 3' - elif minor == 0x01: - self._alias = 'TLS 1.0' - self._name = 'Transport Layer Security' - elif minor == 0x02: - self._alias = 'TLS 1.1' - self._name = 'Transport Layer Security' - elif minor == 0x03 and self._tls_negotiates(schema, TLS13_VERSION, dtls=False): + # NOTE: TLS 1.3 is checked first, and at 0x0301 as well as 0x0303: + # an initial ClientHello may carry 0x0301 "for compatibility + # purposes" (RFC 9846, section 5.1), so a 0x0301 record negotiating + # TLS 1.3 is not TLS 1.0. Any other legacy version names no known + # tier -- TLS 1.3 never puts 0x0304 on the record layer -- so it keeps + # the generic ``TLS`` alias rather than claiming a version it does not + # carry. + 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' - self._name = 'Transport Layer Security' + 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 1.2' - self._name = 'Transport Layer Security' + self._alias = 'TLS' content = self._read_tls_content(schema.content_type, schema.fragment, dtls=False) tls = Data_TLS( diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index 478960b838..49279805a1 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -598,13 +598,15 @@ def test_a_dtls_change_cipher_spec_at_epoch_zero_with_the_wrong_length_raises(se DTLS(wire, len(wire)) -def _tls_client_hello_wire(*, supported_versions: 'bytes | None' = None) -> bytes: +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' @@ -617,10 +619,11 @@ def _tls_client_hello_wire(*, supported_versions: 'bytes | None' = None) -> byte 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\x03\x03' + len(handshake).to_bytes(2, 'big') + handshake + return b'\x16' + record_version + len(handshake).to_bytes(2, 'big') + handshake -def _dtls_client_hello_wire(*, supported_versions: 'bytes | None' = None) -> bytes: +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) @@ -634,7 +637,7 @@ def _dtls_client_hello_wire(*, supported_versions: 'bytes | None' = None) -> byt 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\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + record_header = b'\x16' + record_version + bytes(2) + (1).to_bytes(6, 'big') return record_header + len(handshake).to_bytes(2, 'big') + handshake @@ -716,6 +719,77 @@ def test_dtls_0xfefd_negotiating_1_3_dispatches_dtls_1_3(self) -> None: 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.1` + 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.1`).""" + 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 @@ -875,6 +949,99 @@ def test_sslv2_shaped_payload_degrades_to_raw_when_ssl2_is_unavailable(self) -> proto = TCP(raw, len(raw)) self.assertEqual(str(proto.protochain), 'TCP:Raw') + def test_delegated_parse_adopts_both_alias_and_name(self) -> None: + """:meth:`SSL._adopt_dispatched` carries the delegate's :attr:`name` + over alongside its :attr:`alias`, as :meth:`TLS.read` sets both when + it parses the record itself. SSLv2 support (:issue:`2457`) is not on + this checkout, so a stand-in delegate is installed in its place. + + """ + import sys + import types + from unittest import mock + + 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 + + sentinel = Data_TLS(content_type=ContentType.handshake, version=0x0002, + length=3, content=b'\x01\x00\x00') + + class StandInSSL2: + alias = 'SSLv2' + name = 'Secure Sockets Layer version 2' + info = sentinel + _next = None + + def __init__(self, data: bytes, length: int, **kwargs: object) -> None: + self.data = data[:length] + + module = types.ModuleType('pcapkit.protocols.application.ssl2') + module.SSL2 = StandInSSL2 # type: ignore[attr-defined] + + sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header + with mock.patch.dict(sys.modules, {'pcapkit.protocols.application.ssl2': module}): + parsed = TLS(sslv2_shaped, len(sslv2_shaped)) + self.assertEqual(parsed.alias, 'SSLv2') + self.assertEqual(parsed.name, 'Secure Sockets Layer version 2') + self.assertIs(parsed.info, sentinel) + + +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`` now that the name is 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() From 69c2e94442d1052ba5eea3029c884679ef94161b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 15:44:48 -0400 Subject: [PATCH 08/11] docs(application): fix SSL family anchors, index entries and timed prose - cite RFC 9846 Sec.4.3.1 for supported_versions and RFC 9147 Sec.4 for DTLS legacy_record_version - add SSL, SSLv3, TLS and DTLS to the protocols mermaid tree and the ext.rst layer table - block both SSLv2 module names via sys.modules in the unavailable and adopt tests, so they hold across the ssl2 -> sslv2 rename - trim NOTE blocks to two lines; drop timed context and the stray versionchanged under versionadded - pep.rst: drop counts, list every supported version - tls.rst: 0x0301 row covers ServerHello; records branded per legacy version - round-trip cases at record versions 0x0300-0x0302 and 0xFEFF --- docs/source/contributing/pep.rst | 20 +++--- docs/source/ext.rst | 8 +++ .../pcapkit/protocols/application/dtls.rst | 5 +- .../pcapkit/protocols/application/tls.rst | 9 ++- docs/source/pcapkit/protocols/index.rst | 11 ++- pcapkit/protocols/application/dtls.py | 44 ++++-------- pcapkit/protocols/application/ssl.py | 64 ++++++----------- pcapkit/protocols/application/tls.py | 27 ++----- pcapkit/protocols/transport/sctp.py | 7 +- .../application/test_ssl_tls_dtls_unit.py | 72 +++++++++++-------- .../test_tls_dtls_roundtrip_unit.py | 24 ++++--- 11 files changed, 136 insertions(+), 155 deletions(-) diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 97a2d48a62..c1c5583467 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -50,12 +50,11 @@ the ones outstanding are, 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, and ``LINUX_SLL`` with ``LINUX_SLL2``. **Done** for the third such -pair, TLS/SSL with DTLS: :class:`~pcapkit.protocols.application.ssl.SSL` is -the shared record-layer base, with -:class:`~pcapkit.protocols.application.tls.TLS` and -:class:`~pcapkit.protocols.application.dtls.DTLS` as its two concrete -implementations -- see the `DTLS`_ section below. +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 @@ -228,9 +227,10 @@ DTLS ~~~~ **Done** for the record layer and handshake message framing: -:class:`~pcapkit.protocols.application.tls.TLS` (TLS 1.2, :rfc:`5246`; TLS -1.3, :rfc:`9846`) and :class:`~pcapkit.protocols.application.dtls.DTLS` -(DTLS 1.2, :rfc:`6347`; DTLS 1.3, :rfc:`9147`) share +: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, @@ -1098,7 +1098,7 @@ stub. **Done within wave 2 — TLS/SSL with DTLS.** See the `DTLS`_ section above for what landed (the shared :class:`~pcapkit.protocols.application.ssl.SSL` base, -both concrete protocols, and handshake message framing) and what is left +TLS, DTLS, and handshake message framing) and what is left (DTLS handshake-fragment reassembly, tracked as its own follow-up). **Done ahead of wave 2 — handlers for the** ``DLT_NULL`` **and** ``DLT_RAW`` 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/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst index c7b5e99c32..c87307740a 100644 --- a/docs/source/pcapkit/protocols/application/dtls.rst +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -36,7 +36,7 @@ every version and :meth:`~pcapkit.protocols.application.dtls.DTLS.read` sets - ``DTLS`` DTLS 1.3 never puts its own ``0xFEFC`` on the record layer -(:rfc:`9147#section-4.1`), so it is told apart only by the +(: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. @@ -47,8 +47,7 @@ Known Limitation DTLS fragments a handshake message when it does not fit in one datagram (:rfc:`6347#section-4.2.3`); reassembling that would need :class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` keyed on the -UDP 5-tuple plus epoch, which is substantial enough that it did not fit -cleanly alongside this dissector in the same PR (:issue:`2440`). A fragment's +UDP 5-tuple plus epoch (:issue:`2440`). A fragment's :attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.is_fragmented` says whether this is the case; such a message's :attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` is kept diff --git a/docs/source/pcapkit/protocols/application/tls.rst b/docs/source/pcapkit/protocols/application/tls.rst index ddd67f88f1..088b0f9863 100644 --- a/docs/source/pcapkit/protocols/application/tls.rst +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -42,8 +42,8 @@ whatever the version. * - ``0x0303`` - ``TLS 1.2`` - ``Transport Layer Security`` - * - ``0x0303``, or ``0x0301`` on an initial ``ClientHello``, whose - ``supported_versions`` extension lists ``0x0304`` + * - ``0x0301`` or ``0x0303``, on a ``ClientHello`` or ``ServerHello`` + whose ``supported_versions`` extension lists ``0x0304`` - ``TLS 1.3`` - ``Transport Layer Security`` * - any other ``0x03xx`` @@ -55,6 +55,11 @@ TLS 1.3 never puts its own ``0x0304`` on the record layer ``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',)`` 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/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py index 907390b783..7972c0db07 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -28,9 +28,7 @@ DTLS fragments a handshake message when it does not fit in one datagram (:rfc:`6347#section-4.2.3`); reassembling that would need :class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` keyed on the -UDP 5-tuple plus epoch, which is substantial enough that it did not fit -cleanly alongside this dissector in the same PR (:issue:`2437`; see the -follow-up issue it links for exactly what is missing). A fragment's +UDP 5-tuple plus epoch (:issue:`2440`). A fragment's :attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.is_fragmented` says whether this is the case; such a message's :attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` is kept @@ -41,30 +39,20 @@ the common case: a ``ClientHello`` or ``ServerHello`` that fits one datagram never fragments. -**DTLS 1.3's unified record header is not decoded** (:issue:`2467`, which -depends on this PR landing first). DTLS 1.3 records before the handshake -completes still use the legacy ``DTLSPlaintext`` header this module reads -- -the same shape DTLS 1.2 uses, with ``legacy_record_version`` frozen at DTLS -1.2's value (:rfc:`9147#section-4.1`) -- so a DTLS 1.3 record identified by -:meth:`read` decodes exactly that traffic. The *unified header* -:rfc:`9147#section-4` introduces for records once a connection has a -negotiated epoch has no content-type byte at all, so +**DTLS 1.3's unified record header is not decoded** (:issue:`2467`). DTLS 1.3 +records before the handshake completes still use the legacy ``DTLSPlaintext`` +header this module reads -- the same shape DTLS 1.2 uses, with +``legacy_record_version`` frozen at DTLS 1.2's value (:rfc:`9147#section-4`) -- +so a DTLS 1.3 record identified by :meth:`read` decodes exactly that traffic. +The *unified header* :rfc:`9147#section-4` introduces for records once a +connection has a negotiated epoch has no content-type byte at all, so :meth:`SSL._validate_tls_record ` rejects it outright -- its first octet never falls inside the assigned content-type range this module checks -- and it degrades to -:class:`~pcapkit.protocols.misc.raw.Raw`, which is the honest answer until -that follow-up lands. +:class:`~pcapkit.protocols.misc.raw.Raw`. .. versionadded:: 1.6.0 -.. versionchanged:: 1.6.0 - - :class:`DTLS` identifies DTLS 1.0, DTLS 1.2, or DTLS 1.3's legacy-format - records and reports the identified tier through :attr:`~DTLS.alias` -- - :data:`self ` throughout, never a distinct per-version instance -- - the same shape :class:`~pcapkit.protocols.link.arp.ARP` already uses for - its own family (:issue:`2437`). - """ from typing import TYPE_CHECKING, cast @@ -88,10 +76,10 @@ #: 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.1`). +#: 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.1`). +#: DTLS 1.0's on an initial ``ClientHello`` (:rfc:`9147#section-4`). _DTLS13_LEGACY_VERSIONS = frozenset({_DTLSV1_0_VERSION, _DTLSV1_2_VERSION}) @@ -304,7 +292,7 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> :meth:`ARP.read `'s own ``self._acnm = ...`` dispatch by operation code. DTLS 1.3's legacy-format records freeze theirs at DTLS 1.2's value -- or DTLS - 1.0's on an initial ``ClientHello`` (:rfc:`9147#section-4.1`) -- and + 1.0's on an initial ``ClientHello`` (:rfc:`9147#section-4`) -- and are told apart by peeking the ``supported_versions`` extension (:meth:`SSL._tls_negotiates `). Otherwise DTLS 1.0 (``0xFEFF``) and DTLS 1.2 (``0xFEFD``) are named @@ -345,12 +333,8 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 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, and at DTLS 1.0's value as well as - # DTLS 1.2's: an initial ClientHello may carry {254, 255} "for - # compatibility purposes" (RFC 9147, section 4.1). Any other legacy - # version -- DTLS 1.3's own 0xFEFC included, which never appears on - # the record layer -- names no known tier and keeps the generic - # ``DTLS`` alias rather than claiming a version it does not carry. + # 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: diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index 0c6b9e9448..acfaeefbcb 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -83,11 +83,10 @@ #: during the handshake -- it is not on the wire of the record itself. Reading #: ``length`` at the fixed offset for a type-25 record decodes the tail of the #: CID as the length field, fabricating a fragment that was never on the wire -#: (confirmed: an 8-octet CID made an 8-octet fragment read back as -#: ``length=17``). Parsing it correctly needs this dissector to track the -#: negotiated CID length per connection, which the current stateless, -#: per-packet schema read has nowhere to keep; until a follow-up adds that, -#: type 25 is excluded here so it falls back to +#: (an 8-octet CID makes an 8-octet fragment read back as ``length=17``). +#: Parsing it correctly needs the negotiated CID length per connection, which +#: the stateless, per-packet schema read has nowhere to keep, so type 25 is +#: excluded here and falls back to #: :class:`~pcapkit.protocols.misc.raw.Raw` instead of being decoded wrong. _WIRE_CONTENT_TYPES = frozenset({ Enum_ContentType.change_cipher_spec, @@ -99,7 +98,7 @@ Enum_ContentType.return_routability_check, }) -#: The IANA ``supported_versions`` extension type (:rfc:`9846#section-4.2.1`). +#: The IANA ``supported_versions`` extension type (:rfc:`9846#section-4.3.1`). #: TLS 1.3 and DTLS 1.3 negotiate the *real* version here rather than in the #: record header's own legacy version, which both freeze at their immediate #: predecessor's value for backward compatibility -- see :data:`TLS13_VERSION` @@ -107,7 +106,7 @@ _EXT_SUPPORTED_VERSIONS = 43 #: The wire value a ``supported_versions`` extension carries for TLS 1.3 -#: (:rfc:`9846#section-4.2.1`). Never the record layer's own +#: (:rfc:`9846#section-4.3.1`). Never the record layer's own #: ``legacy_version``, which TLS 1.3 freezes at TLS 1.2's ``0x0303``. TLS13_VERSION = 0x0304 #: Same, for DTLS 1.3 (:rfc:`9147#section-5.3`). The record layer stays @@ -142,7 +141,7 @@ def _negotiated_versions(message: 'Any') -> 'tuple[int, ...]': 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.2.1`). + # 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) @@ -292,19 +291,10 @@ def _validate_tls_record(self, schema: 'Any', *, available: 'int', dtls: 'bool') answer, because the fragment this layer holds is not a record. """ - # NOTE: ``schema.content_type`` is the registry member when this - # schema came from a genuine parse (``EnumField.post_process`` - # resolves it there), but a plain :obj:`int` when it came from - # ``make()`` instead -- :meth:`Protocol._make_index` with - # ``pack=False`` returns the bare index despite its ``cast`` to the - # enum type, which is a typing fiction rather than a runtime one. So - # it is resolved through the registry explicitly here rather than - # read off the value directly, to cover both. - # - # NOTE: checked against ``_WIRE_CONTENT_TYPES`` rather than by asking - # whether the registry named the value: ``invalid(0)`` *is* named, and - # is still not a content type any record carries, so a name-based test - # accepted ``00 03 03 ...`` as a TLS record and decoded its fragment. + # 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( @@ -403,8 +393,7 @@ def _tls_content_is_cleartext(self, content_type: 'Enum_ContentType', fragment: fabricates content out of pseudorandom octets or fails and drops the whole record to :class:`~pcapkit.protocols.misc.raw.Raw`, losing the record header that *was* in the clear -- the bug this method exists to - avoid for all three types rather than just ``handshake``, where it was - first caught. + avoid for all three types rather than just ``handshake``. The two protocols answer this differently because DTLS gives a direct answer and TLS does not: @@ -573,8 +562,7 @@ def _read_tls_handshake_messages(self, fragment: 'bytes', *, not reassembled in this version (:issue:`2440`), so its body is kept as the single fragment's raw octets regardless of ``msg_type``. Every other ``msg_type`` is message-TYPE-level - framing only: its body is kept undecoded, as :issue:`2437` scopes - this PR to. + framing only: its body is kept undecoded (:issue:`2437`). """ from pcapkit.protocols.data.application.dtls import \ @@ -590,10 +578,8 @@ def _read_tls_handshake_messages(self, fragment: 'bytes', *, pos += 4 is_fragmented = False - # NOTE: 0 rather than ``None`` -- unused by the ``else`` branch - # below when ``dtls`` is false, so there is no "not yet known" - # state for a type to carry, only a value :class:`DTLSHandshake` - # declares as plain ``int``. + # 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) @@ -656,14 +642,8 @@ def _make_tls_handshake_messages(self, messages: 'tuple[Any, ...]', *, else: body_bytes = bytes(body) - # NOTE: the message's own ``length`` is trusted only when it - # cannot be recovered any other way -- a fragmented DTLS message, - # where this fragment's body is a strict subset of the total. - # Otherwise it is recomputed from ``body_bytes``, so a message - # built by hand need not get a redundant length right, and a - # parse-then-rebuild stays exact either way (a read message's - # ``length`` already equals its body's, there or in DTLS's - # unfragmented case). + # 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) @@ -747,12 +727,8 @@ def _read_tls_client_hello(self, body: 'bytes', *, dtls: 'bool') -> 'Data_Client 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`), so an - # odd length never has an RFC-compliant decoding -- and without this - # check, the last, incomplete pair would quietly decode as one lone - # octet rather than fail, and :meth:`_make_tls_client_hello` would then - # re-pad it to 2 octets on rebuild, losing byte-exactness silently - # rather than refusing to parse. + # 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 ' @@ -915,7 +891,7 @@ def _read_tls_extensions(self, data: 'bytes') -> 'tuple[Data_Extension, ...]': An extension's own body is not decoded (:issue:`2437`): it is kept as :attr:`~pcapkit.protocols.data.application.tls.Extension.data`, - undecoded, which is what this PR scopes extension support to. + undecoded. Args: data: The extension list, i.e. one or more concatenated diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index 0e9a33151b..5601e7b547 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -67,16 +67,6 @@ .. versionadded:: 1.6.0 -.. versionchanged:: 1.6.0 - - :class:`TLS` identifies SSLv3, TLS 1.0, TLS 1.1, TLS 1.2, or TLS 1.3 (and, - where available, SSLv2) and reports the identified tier through - :attr:`~TLS.alias` -- :class:`self ` throughout, never a distinct - per-version instance -- the same shape - :class:`~pcapkit.protocols.link.arp.ARP` already uses for its own family - (:issue:`2437`). :class:`SSLv3` remains available for explicit, manual - construction only; :meth:`TLS.read` never constructs one itself. - """ from typing import TYPE_CHECKING, cast @@ -195,8 +185,7 @@ class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): 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, now that - this module parses the record layer it carries. See + at port 443, the ``https`` service name IANA assigns there. See :func:`pcapkit.foundation.registry.protocols.register_tcp`. """ @@ -295,9 +284,8 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': try: from pcapkit.protocols.application.ssl2 import SSL2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel except ImportError: - pass # SSLv2 support (#2457) is not present on this - # checkout -- fall through to the TLS/SSLv3+ parse - # below, exactly as if this identification did not exist. + pass # SSLv2 support (#2457) is unavailable -- fall through + # to the TLS/SSLv3+ parse below. else: obj = SSL2(self._data, length, **kwargs) return self._adopt_dispatched(obj) @@ -439,13 +427,8 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' 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, and at 0x0301 as well as 0x0303: - # an initial ClientHello may carry 0x0301 "for compatibility - # purposes" (RFC 9846, section 5.1), so a 0x0301 record negotiating - # TLS 1.3 is not TLS 1.0. Any other legacy version names no known - # tier -- TLS 1.3 never puts 0x0304 on the record layer -- so it keeps - # the generic ``TLS`` alias rather than claiming a version it does not - # carry. + # 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' diff --git a/pcapkit/protocols/transport/sctp.py b/pcapkit/protocols/transport/sctp.py index 77eb5b8a8c..58674c6655 100644 --- a/pcapkit/protocols/transport/sctp.py +++ b/pcapkit/protocols/transport/sctp.py @@ -333,13 +333,12 @@ 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 this registry still maps it straight to + 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` -- :mod:`pcapkit` implements DTLS - now, but rewiring this dispatch through it, and deciding what an - undecrypted DTLS-wrapped NGAP PDU should report, is its own follow-up. It + :class:`~pcapkit.protocols.misc.raw.Raw`: what an undecrypted + DTLS-wrapped NGAP PDU should report is not yet decided. It is registered so that the PPID is *named* in the protochain rather than reported as an unassigned number. diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index 49279805a1..a39d12c180 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -17,6 +17,10 @@ #: :mod:`tests.protocols.transport.test_tcp_http_dispatch_unit`'s port-80 header. _TCP_TO_443 = bytes.fromhex('c35001bb00000001000000015010ffff00000000') +#: Both module names the SSLv2 dissector (:issue:`2457`) may live under, so a +#: test patching them holds whichever one :meth:`TLS.read` imports. +_SSLV2_MODULES = ('pcapkit.protocols.application.ssl2', 'pcapkit.protocols.application.sslv2') + def _udp_to_5684(payload: bytes) -> bytes: """An 8-octet UDP header to port 5684 -- where DTLS is bound -- plus @@ -85,13 +89,12 @@ def test_tcp_and_udp_dispatch_tables_name_tls_and_dtls(self) -> None: 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. - It used to raise, on the theory that a cleartext CCS is always 1 - octet -- true, but a CCS sent mid-renegotiation (after keys exist) - is ciphertext under the same content type, and ciphertext is never - exactly 1 octet either, given MAC/tag overhead. Same bug class as - the already-fixed content-type-21/22 cases: the fix is to keep the - fragment opaque rather than raise, so the record (and its cleartext - header) survives instead of degrading to :class:`Raw`. + 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 @@ -201,13 +204,11 @@ def test_make_content_accepts_a_bytearray_for_an_unknown_content_type(self) -> N class RecordValidationTests(unittest.TestCase): """Every branch of ``SSL._validate_tls_record``, and what dispatch does with it. - The content-type check is the one with a confirmed exploit behind it: it - used to ask whether :class:`~pcapkit.const.tls.content_type.ContentType` - had *named* the value, and ``invalid(0)`` is named -- a sentinel - :rfc:`9846#section-5.1` defines so a record carrying it can be rejected, - which IANA correspondingly leaves inside its Unassigned 0-19 band. So - ``00 03 03 00 04 deadbeef`` on TCP:443 was accepted and its fragment - decoded. It is an allowlist of the assigned 20-27 now. + 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. """ @@ -642,8 +643,8 @@ def _dtls_client_hello_wire(*, supported_versions: 'bytes | None' = None, class VersionDispatchTests(unittest.TestCase): - """:meth:`TLS.read`/:meth:`DTLS.read` -- the restructure :issue:`2437`'s - follow-up asked for: identifying the concrete tier and setting + """: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 @@ -764,7 +765,7 @@ def test_tls_0301_client_hello_negotiating_1_3_dispatches_tls_1_3(self) -> None: 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.1` + 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 @@ -778,7 +779,7 @@ def test_dtls_unknown_record_version_keeps_the_generic_alias(self) -> None: 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.1`).""" + on the record layer (:rfc:`9147#section-4`).""" from pcapkit.protocols.application.dtls import DTLS supported_versions = b'\x02' + (0xFEFC).to_bytes(2, 'big') @@ -931,20 +932,25 @@ def test_too_short_to_tell_reads_as_sslv2(self) -> None: self.assertTrue(_looks_like_sslv2(b'\x16')) # no second octet to check def test_sslv2_shaped_payload_degrades_to_raw_when_ssl2_is_unavailable(self) -> None: - """SSLv2 support (:issue:`2457`) is not on this checkout. - - Dispatch still identifies the shape and tries to import - :class:`~pcapkit.protocols.application.ssl2.SSL2`; the - :exc:`ImportError` falls through to the TLS-family dispatch, whose - own validation then rejects the payload -- degrading to + """With SSLv2 support (:issue:`2457`) unavailable, dispatch still + identifies the shape and tries to import it; the :exc:`ImportError` + falls through to the TLS-family dispatch, whose own validation then + rejects the payload -- degrading to :class:`~pcapkit.protocols.misc.raw.Raw` rather than crashing. + Both module names the SSLv2 dissector may live under are blocked, so + the import fails whichever one :meth:`TLS.read` imports. + """ + import sys + from unittest import mock + from pcapkit.protocols.transport.tcp import TCP sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header raw = _TCP_TO_443 + sslv2_shaped - with warnings.catch_warnings(): + unavailable = dict.fromkeys(_SSLV2_MODULES) + with mock.patch.dict(sys.modules, unavailable), warnings.catch_warnings(): warnings.simplefilter('ignore') proto = TCP(raw, len(raw)) self.assertEqual(str(proto.protochain), 'TCP:Raw') @@ -952,8 +958,9 @@ def test_sslv2_shaped_payload_degrades_to_raw_when_ssl2_is_unavailable(self) -> def test_delegated_parse_adopts_both_alias_and_name(self) -> None: """:meth:`SSL._adopt_dispatched` carries the delegate's :attr:`name` over alongside its :attr:`alias`, as :meth:`TLS.read` sets both when - it parses the record itself. SSLv2 support (:issue:`2457`) is not on - this checkout, so a stand-in delegate is installed in its place. + it parses the record itself. A stand-in SSLv2 delegate + (:issue:`2457`) is installed under both module names it may live + under, so the test does not depend on the real one. """ import sys @@ -976,11 +983,14 @@ class StandInSSL2: def __init__(self, data: bytes, length: int, **kwargs: object) -> None: self.data = data[:length] - module = types.ModuleType('pcapkit.protocols.application.ssl2') - module.SSL2 = StandInSSL2 # type: ignore[attr-defined] + stand_ins = {} + for name in _SSLV2_MODULES: + module = types.ModuleType(name) + module.SSL2 = module.SSLv2 = StandInSSL2 # type: ignore[attr-defined] + stand_ins[name] = module sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header - with mock.patch.dict(sys.modules, {'pcapkit.protocols.application.ssl2': module}): + with mock.patch.dict(sys.modules, stand_ins): parsed = TLS(sslv2_shaped, len(sslv2_shaped)) self.assertEqual(parsed.alias, 'SSLv2') self.assertEqual(parsed.name, 'Secure Sockets Layer version 2') @@ -1022,7 +1032,7 @@ def test_registered_in_the_protocol_registry(self) -> None: getattr(pcapkit.protocols, name)) def test_string_lookup_matches_a_parsed_record(self) -> None: - """``'SSL' in pkt`` agrees with ``SSL in pkt`` now that the name is registered.""" + """``'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 diff --git a/tests/protocols/application/test_tls_dtls_roundtrip_unit.py b/tests/protocols/application/test_tls_dtls_roundtrip_unit.py index 30665bbd16..38373281bf 100644 --- a/tests/protocols/application/test_tls_dtls_roundtrip_unit.py +++ b/tests/protocols/application/test_tls_dtls_roundtrip_unit.py @@ -2,12 +2,12 @@ """TLS and DTLS records rebuild byte for byte. Hand-crafted wire octets for every content type and handshake-message shape -this PR implements (:issue:`2437`): ``handshake`` carrying a ``ClientHello``, +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 this PR does not reassemble one -- see +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 @@ -83,6 +83,12 @@ def _server_hello_body(extensions: 'bytes | None' = b'') -> bytes: 'application_data': _tls_record(23, b'hello'), # :rfc:`9853`, the newest ContentType assignment. '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'), } @@ -149,6 +155,10 @@ def _dtls_handshake(msg_type: int, body: bytes, *, message_seq: int, fragment_of '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'), } @@ -227,11 +237,9 @@ def test_malformed_client_hello_raises_protocol_error(self) -> None: def test_alert_record_with_excess_octets_keeps_its_octets(self) -> None: """A 3-octet TLS ``alert`` fragment is not a malformed cleartext alert. - It used to raise, on the theory that any alert fragment must be - exactly 2 octets, cleartext or not -- but an encrypted alert is - never exactly 2 octets either, so a non-2-octet fragment is kept - opaque now rather than raising, the same discipline applied to the - encrypted close_notify case below. + 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 @@ -271,7 +279,7 @@ def test_make_then_parse_is_byte_exact(self) -> None: self.assertEqual(bytes(schema).hex(), wire.hex()) def test_fragment_is_flagged_and_body_kept_undecoded(self) -> None: - """The known limitation this PR documents: no reassembly yet.""" + """The documented limitation: a fragmented message is not reassembled.""" from pcapkit.protocols.application.dtls import DTLS wire = DTLS_RECORDS['handshake: fragment 1 of 2'] From cd5493f9bb45b7edaef40fb3b6d5590501d3002c Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 16:49:12 -0400 Subject: [PATCH 09/11] fix(application): delegate SSLv2 to sslv2.SSLv2 and keep TLS-shaped records TLS - TLS.read imports pcapkit.protocols.application.sslv2.SSLv2 (#2466's name); docstrings follow. - _looks_like_sslv2 reads any first octet below 0x40 followed by 0x03 as TLS, so heartbeat/ack/return_routability_check and unaccepted types stay with the TLS validator instead of misparsing as SSLv2. - Tests stub the module under the sslv2 name and cover an SSLv2 CLIENT-HELLO on TCP 443 and every first octet 0-255. - SSL/DTLS docs state where the DTLS epoch and sequence number live; dtls.rst lists the unified-header and RFC 9146 limits; RFC 9846 also obsoletes 5246. - Timed prose removed from pep.rst, ssl.py, dtls.py, sctp.py, ngap.py. TLS/DTLS, application, dispatch, export and docs tests pass. --- docs/source/contributing/pep.rst | 43 +++-- .../pcapkit/protocols/application/dtls.rst | 18 +- .../pcapkit/protocols/application/ngap.rst | 4 +- .../pcapkit/protocols/application/ssl.rst | 20 ++- .../pcapkit/protocols/application/tls.rst | 3 +- examples/generators/dispatch.py | 6 +- pcapkit/const/tls/content_type.py | 4 +- pcapkit/protocols/application/dtls.py | 37 ++-- pcapkit/protocols/application/ngap.py | 4 +- pcapkit/protocols/application/ssl.py | 33 ++-- pcapkit/protocols/application/tls.py | 83 ++++----- pcapkit/protocols/data/application/dtls.py | 2 +- pcapkit/protocols/transport/sctp.py | 11 +- .../application/test_ssl_tls_dtls_unit.py | 160 ++++++++++++------ .../test_tls_dtls_roundtrip_unit.py | 2 +- tests/protocols/transport/test_sctp_unit.py | 4 +- 16 files changed, 246 insertions(+), 188 deletions(-) diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index c1c5583467..e65acf9f76 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -239,31 +239,28 @@ and the fragment header each handshake message carries) staying in ``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 was never needed to make the record structure legible, -in the same way :class:`ESP ` parses +separate question and is not needed to make the record structure legible, in +the same way :class:`ESP ` parses without keys. -**Not yet done: reassembling a handshake message DTLS has fragmented across -several records** (:rfc:`6347#section-4.2.3`). That needs a +**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, which is substantial enough that it was left -for a follow-up rather than landing alongside the dissector -(:issue:`2440`). Until then, a fragmented message's body is kept as that one +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 attempted as a decode only a slice of the message could ever -back correctly. An unfragmented message, the common case, is unaffected. +-- 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 `__, is **not** -unblocked by this: :attr:`SCTP.__proto__ ` -still maps PPID 66 straight to :class:`~pcapkit.protocols.application.ngap.NGAP` -rather than through :class:`~pcapkit.protocols.application.dtls.DTLS`, so those -bytes still degrade straight to :class:`~pcapkit.protocols.misc.raw.Raw`. Rewiring -that dispatch, and deciding what an undecrypted DTLS-wrapped NGAP PDU should -report, is left to its own follow-up. +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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -320,11 +317,9 @@ 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, and TCP 443 (IANA ``https``) - is bound to :class:`~pcapkit.protocols.application.tls.TLS`, now that it - parses the record layer de-facto 443 traffic carries; UDP 5684 (IANA - ``coaps``) is bound to :class:`~pcapkit.protocols.application.dtls.DTLS` - likewise. **8443 is deliberately left unbound**: IANA registers it as + 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 @@ -1096,10 +1091,10 @@ 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.** See the `DTLS`_ section above for -what landed (the shared :class:`~pcapkit.protocols.application.ssl.SSL` base, -TLS, DTLS, and handshake message framing) and what is left -(DTLS handshake-fragment reassembly, tracked as its own follow-up). +**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 diff --git a/docs/source/pcapkit/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst index c87307740a..4f5504b83f 100644 --- a/docs/source/pcapkit/protocols/application/dtls.rst +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -40,7 +40,7 @@ DTLS 1.3 never puts its own ``0xFEFC`` on the record layer ``supported_versions`` peek. A version the table does not name keeps the generic ``DTLS`` alias rather than claiming a specific one. -Known Limitation +Known Limitations ----------------- **A handshake message split across several DTLS records is not reassembled.** @@ -58,6 +58,22 @@ exactly as :class:`~pcapkit.protocols.application.tls.TLS` would, which is the common case: a ``ClientHello`` or ``ServerHello`` that fits one datagram never fragments. +**DTLS 1.3's unified record header is not decoded** (:issue:`2467`). DTLS 1.3 +sends its cleartext records with the legacy ``DTLSPlaintext`` header this +module reads -- the DTLS 1.2 shape, with ``legacy_record_version`` frozen at +DTLS 1.2's value (:rfc:`9147#section-4`). The *unified header* the same +section defines for encrypted records has no content-type octet, so +:meth:`SSL._validate_tls_record ` +rejects it -- its first octet is never an accepted content type -- and it +degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. + +**Connection ID records are not decoded** (``tls12_cid``, :rfc:`9146`). Their +connection ID precedes ``length``, and its length is negotiated during the +handshake rather than carried in the record, so +:meth:`SSL._validate_tls_record ` +rejects content type 25 and the record degrades to +:class:`~pcapkit.protocols.misc.raw.Raw`. + .. versionadded:: 1.6.0 .. raw:: html diff --git a/docs/source/pcapkit/protocols/application/ngap.rst b/docs/source/pcapkit/protocols/application/ngap.rst index 1f4fe326f4..65a499447d 100644 --- a/docs/source/pcapkit/protocols/application/ngap.rst +++ b/docs/source/pcapkit/protocols/application/ngap.rst @@ -92,8 +92,8 @@ Known Limitations * **PPID 66 payloads are not decoded.** NGAP over DTLS over SCTP wraps the ``NGAP-PDU`` in a DTLS record, and :attr:`SCTP.__proto__ - ` still maps PPID 66 - straight to :class:`~pcapkit.protocols.application.ngap.NGAP` rather than + ` 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 diff --git a/docs/source/pcapkit/protocols/application/ssl.rst b/docs/source/pcapkit/protocols/application/ssl.rst index 663a3b62bf..c44e49b423 100644 --- a/docs/source/pcapkit/protocols/application/ssl.rst +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -16,15 +16,17 @@ family [*]_ -- the SSL versions, Transport Layer Security and Datagram TLS: :meth:`SSL.id ` lists the direct concrete subclasses, so a lookup keyed on ``SSL`` matches any of them. -The record-layer header -- a :class:`~pcapkit.const.tls.content_type.ContentType`, -a legacy record version, and a length -- is byte-identical in shape between -TLS (:rfc:`9846#section-5.1`) and DTLS (:rfc:`9147#section-4`); DTLS's own -divergence (an explicit epoch and sequence number in the record header, and -handshake-message fragmentation that a reliable TLS stream never needs) stays -entirely in :class:`~pcapkit.protocols.application.dtls.DTLS`. This base class -carries the logic the two genuinely share -- content-type dispatch, handshake- -message decoding, and the record-level validation that keeps dispatch from -accepting bytes that cannot be a genuine record. +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 diff --git a/docs/source/pcapkit/protocols/application/tls.rst b/docs/source/pcapkit/protocols/application/tls.rst index 088b0f9863..0168bc055c 100644 --- a/docs/source/pcapkit/protocols/application/tls.rst +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -8,7 +8,8 @@ TLS/SSL - Transport Layer Security :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:`8446`) -- at the record layer, plus +1.3 (:rfc:`9846`, which obsoletes :rfc:`8446` and :rfc:`5246`) -- at the +record layer, plus handshake message TYPE-level framing. Version Identification diff --git a/examples/generators/dispatch.py b/examples/generators/dispatch.py index d5b1a06eb6..cd379258d8 100644 --- a/examples/generators/dispatch.py +++ b/examples/generators/dispatch.py @@ -60,7 +60,7 @@ :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 :attr:`SCTP.__proto__ ` -still maps it straight to NGAP rather than through DTLS; the other degrades +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. @@ -929,8 +929,8 @@ 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 the registry still maps - # it straight to NGAP rather than through DTLS -- registered so the PPID + # 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. diff --git a/pcapkit/const/tls/content_type.py b/pcapkit/const/tls/content_type.py index 8f93db97d8..2adb95e502 100644 --- a/pcapkit/const/tls/content_type.py +++ b/pcapkit/const/tls/content_type.py @@ -7,8 +7,8 @@ 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` for DTLS -- -is identical in shape between the two, and this is the type it dispatches on. +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 diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py index 7972c0db07..24b8a8b837 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -7,21 +7,22 @@ :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`) -- the -epoch/sequence-number fields and the handshake-message fragment header that -set the family apart from :class:`~pcapkit.protocols.application.tls.TLS`, -whose reliable, ordered transport needs neither, stay shared across the -whole family via :class:`~pcapkit.protocols.application.ssl.SSL`. +1.2 (:rfc:`6347`), and DTLS 1.3's legacy-format records (:rfc:`9147`). The +epoch and sequence number in the record header, which set DTLS apart from +:class:`~pcapkit.protocols.application.tls.TLS`, are declared in +:class:`~pcapkit.protocols.schema.application.dtls.DTLS` and read here; the +handshake-message fragment header is read by the handshake decoding shared +with TLS in :class:`~pcapkit.protocols.application.ssl.SSL`. :attr:`UDP.__proto__ ` binds -this class at port 5684; its own :meth:`read` parses the (always -wire-identical) record header, then sets :attr:`~DTLS.alias` according to +this class at port 5684; its own :meth:`read` parses the ``DTLSPlaintext`` +record header, then sets :attr:`~DTLS.alias` according to the record's legacy version -- mirroring :class:`~pcapkit.protocols.link.arp.ARP`'s own dynamic dispatch over RARP/DRARP/InARP by operation code, and :class:`~pcapkit.protocols.application.tls.TLS`'s own dispatch over its family. -Known Limitation +Known Limitations ----------------- **A handshake message split across several DTLS records is not reassembled.** @@ -40,15 +41,19 @@ never fragments. **DTLS 1.3's unified record header is not decoded** (:issue:`2467`). DTLS 1.3 -records before the handshake completes still use the legacy ``DTLSPlaintext`` -header this module reads -- the same shape DTLS 1.2 uses, with -``legacy_record_version`` frozen at DTLS 1.2's value (:rfc:`9147#section-4`) -- -so a DTLS 1.3 record identified by :meth:`read` decodes exactly that traffic. -The *unified header* :rfc:`9147#section-4` introduces for records once a -connection has a negotiated epoch has no content-type byte at all, so +sends its cleartext records with the legacy ``DTLSPlaintext`` header this +module reads -- the DTLS 1.2 shape, with ``legacy_record_version`` frozen at +DTLS 1.2's value (:rfc:`9147#section-4`). The *unified header* the same +section defines for encrypted records has no content-type octet, so :meth:`SSL._validate_tls_record ` -rejects it outright -- its first octet never falls inside the assigned -content-type range this module checks -- and it degrades to +rejects it -- its first octet is never an accepted content type -- and it +degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. + +**Connection ID records are not decoded** (``tls12_cid``, :rfc:`9146`). Their +connection ID precedes ``length``, and its length is negotiated during the +handshake rather than carried in the record, so +:meth:`SSL._validate_tls_record ` +rejects content type 25 and the record degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. .. versionadded:: 1.6.0 diff --git a/pcapkit/protocols/application/ngap.py b/pcapkit/protocols/application/ngap.py index 33e1f21dbe..4f196fee97 100644 --- a/pcapkit/protocols/application/ngap.py +++ b/pcapkit/protocols/application/ngap.py @@ -77,8 +77,8 @@ * **PPID 66 payloads are not decoded.** NGAP over DTLS over SCTP wraps the ``NGAP-PDU`` in a DTLS record, and :attr:`SCTP.__proto__ - ` still maps PPID 66 - straight to :class:`~pcapkit.protocols.application.ngap.NGAP` rather than + ` 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 diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index acfaeefbcb..1027f2025c 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -12,20 +12,18 @@ through :class:`~pcapkit.protocols.application.tls.SSLv3`) and Datagram TLS (:class:`~pcapkit.protocols.application.dtls.DTLS`). -The record-layer header -- a :class:`~pcapkit.const.tls.content_type.ContentType`, -a legacy record version, and a length -- is byte-identical in shape between -TLS (:rfc:`9846#section-5.1`) and DTLS (:rfc:`9147#section-4`); DTLS's own -divergence (an explicit epoch and sequence number in the record header, and -handshake-message fragmentation that a reliable TLS stream never needs) stays -entirely in :class:`~pcapkit.protocols.application.dtls.DTLS`. This base class -carries the logic the two genuinely share -- content-type dispatch and -handshake-message decoding -- parameterised by a ``dtls`` flag where DTLS's -extra fragment header has to be read or written; the record header fields -themselves are declared separately in each subclass's own schema +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`), since DTLS inserts -its epoch and sequence number *between* the version and the length rather than -appending them. +: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 @@ -413,11 +411,8 @@ def _tls_content_is_cleartext(self, content_type: 'Enum_ContentType', fragment: * ``change_cipher_spec`` -- the same reasoning, at exactly 1 octet. Any other content type is reported as *not* cleartext -- a miss lands - on the conservative side, same as :func:`_tls_handshake_tiles` -- even - though callers today only consult this for the three above, so that a - future content type ever routed through this method without adding a - branch for it fails closed instead of silently reintroducing the bug - this method exists to avoid. + on the conservative side, same as :func:`_tls_handshake_tiles` -- so a + content type without a branch here fails closed. Args: content_type: Record content type. @@ -559,7 +554,7 @@ def _read_tls_handshake_messages(self, fragment: 'bytes', *, only when the whole message is present -- always true for TLS, and true for DTLS only when the message is not fragmented across several records. A DTLS message that is split across records is - not reassembled in this version (:issue:`2440`), so its body is + not reassembled (:issue:`2440`), so its body is kept as the single fragment's raw octets regardless of ``msg_type``. Every other ``msg_type`` is message-TYPE-level framing only: its body is kept undecoded (:issue:`2437`). diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index 5601e7b547..a1a188f4e3 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -9,7 +9,7 @@ :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:`8446`). :attr:`TCP.__proto__ +which obsoletes :rfc:`8446` and :rfc:`5246`). :attr:`TCP.__proto__ ` binds :class:`TLS` at port 443; its own :meth:`~TLS.read` parses the (always wire-identical) record header, then sets :attr:`~TLS.alias` (and, for SSLv3, :attr:`~TLS.name`) @@ -19,7 +19,7 @@ :class:`~pcapkit.protocols.application.http.HTTP`'s dispatch over distinct per-version classes. Where the payload is SSLv2-shaped instead, :meth:`~TLS.read` delegates to -:class:`~pcapkit.protocols.application.ssl2.SSL2`. +:class:`~pcapkit.protocols.application.sslv2.SSLv2`. Decoded Fields -------------- @@ -116,61 +116,42 @@ #: ``0x0301`` on an initial ``ClientHello`` (:rfc:`9846#section-5.1`). _TLS13_LEGACY_VERSIONS = frozenset({0x0301, 0x0303}) -#: The content types IANA's registry assigns that a genuine *first* record on -#: a fresh connection carries on the wire -- the four -#: :func:`_looks_like_sslv2` tests the second octet of, to tell a real -#: TLS/SSLv3+ record from SSLv2's wire-incompatible, content-type-less header. -#: Deliberately narrower than -#: :data:`~pcapkit.protocols.application.ssl._WIRE_CONTENT_TYPES`: ``heartbeat`` -#: and the DTLS-only ``ack``/``return_routability_check`` do not open a -#: connection, and widening this set only widens the window in which a -#: genuine SSLv2 record (whose second octet is an arbitrary length octet, not -#: always a version byte) could be misread as TLS. -_SSLV3_PLUS_FIRST_OCTETS = frozenset({ - int(Enum_ContentType.change_cipher_spec), - int(Enum_ContentType.alert), - int(Enum_ContentType.handshake), - int(Enum_ContentType.application_data), -}) +#: Exclusive upper bound of a TLS content type that :func:`_looks_like_sslv2` +#: accepts: the first octets SSLv2's padded 3-octet header also uses (top bits +#: ``00``). IANA assigns no content type above it, and :rfc:`9443` reserves +#: 64-255 for other protocols multiplexed on the same port. +_TLS_FIRST_OCTET_LIMIT = 0x40 def _looks_like_sslv2(data: 'bytes') -> 'bool': """Whether ``data`` opens with an SSLv2 record header rather than a TLS one. - SSLv2 carries no content-type byte at all: its record header is either 2 - octets, with the top bit of the first set, or 3, with the top two bits - ``01`` for the escaped form or ``00`` for the plain one (see - :mod:`~pcapkit.protocols.application.ssl2`'s module docstring for the - exact bit layout). A genuine TLS/SSLv3+ record's first octet is always - one of the content types :data:`_SSLV3_PLUS_FIRST_OCTETS` names, and its - second is always :data:`_TLS_VERSION_MAJOR`. - - Only the plain (``00``) 3-octet SSLv2 form is genuinely ambiguous with a - real content-type byte, since content types 20-23 also carry top bits - ``00`` -- resolved the same way the positive identification is, by - testing the second octet too, so only a match on *both* conditions reads - as TLS; everything else, including a buffer too short to carry even one - octet, reads as SSLv2. + A TLS record header (:rfc:`9846#section-5.1`) opens with a content type + and the legacy version's major octet, :data:`_TLS_VERSION_MAJOR`. An + SSLv2 header (draft-hickman-netscape-ssl-00, "SSL Record Header") has no + content type: a first octet with the top bit set opens a 2-octet header, + one with top bits ``01`` an escaped 3-octet header, and one with top bits + ``00`` a padded 3-octet header whose second octet is the low half of the + record length (see :mod:`~pcapkit.protocols.application.sslv2`). + + ``data`` is read as TLS when its first octet is below + :data:`_TLS_FIRST_OCTET_LIMIT` and its second is + :data:`_TLS_VERSION_MAJOR`, whether or not the content type is one + :meth:`SSL._validate_tls_record + ` accepts, so + that it, not SSLv2, rejects the others. Everything else reads as SSLv2, + including a buffer too short to hold both octets. Args: data: The payload, from its first octet. Returns: - :data:`False` only when ``data`` opens with a content type this - module recognises as a TLS/SSLv3+ one immediately followed by - :data:`_TLS_VERSION_MAJOR`; :data:`True` otherwise. + :data:`False` when ``data`` opens with a TLS record header; + :data:`True` otherwise. """ - if not data: - return True - first = data[0] - if first & 0x80: # SSLv2, 2-octet header - return True - if (first & 0xC0) == 0x40: # SSLv2, 3-octet header, escaped - return True - if first in _SSLV3_PLUS_FIRST_OCTETS and len(data) >= 2 and data[1] == _TLS_VERSION_MAJOR: - return False - return True # SSLv2, 3-octet header, plain -- or too short to tell + return not (len(data) >= 2 and data[0] < _TLS_FIRST_OCTET_LIMIT + and data[1] == _TLS_VERSION_MAJOR) class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): @@ -246,7 +227,7 @@ def id(cls) -> 'tuple[Literal["TLS"]]': # type: ignore[override] identified tier apart instead. SSLv2 is not among them either: :meth:`read` may still delegate to it when the payload is shaped that way (see :func:`_looks_like_sslv2`), but doing so hands back - an :class:`~pcapkit.protocols.application.ssl2.SSL2` instance, + an :class:`~pcapkit.protocols.application.sslv2.SSLv2` instance, which carries its own, distinct :meth:`id`. """ @@ -256,7 +237,7 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': """Read Transport Layer Security (TLS), or whatever port 443 actually carries. Where the payload is SSLv2-shaped instead (:func:`_looks_like_sslv2`), - delegates to :class:`~pcapkit.protocols.application.ssl2.SSL2` and + delegates to :class:`~pcapkit.protocols.application.sslv2.SSLv2` and adopts its parse. Otherwise parses the TLS/SSLv3+ record itself via :meth:`_read_tls_record`, which also sets :attr:`alias` (and, for SSLv3, :attr:`name`) according to the record's legacy version -- @@ -272,8 +253,8 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': Raises: ProtocolError: If the record cannot be a genuine TLS/SSLv3+ record -- see :meth:`_read_tls_record` -- or, where - delegated, if :class:`~pcapkit.protocols.application.ssl2.SSL2`'s - own :meth:`read ` + delegated, if :class:`~pcapkit.protocols.application.sslv2.SSLv2`'s + own :meth:`read ` fails. """ @@ -282,12 +263,12 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': if _looks_like_sslv2(self._data[:length]): try: - from pcapkit.protocols.application.ssl2 import SSL2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel + from pcapkit.protocols.application.sslv2 import SSLv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel except ImportError: pass # SSLv2 support (#2457) is unavailable -- fall through # to the TLS/SSLv3+ parse below. else: - obj = SSL2(self._data, length, **kwargs) + obj = SSLv2(self._data, length, **kwargs) return self._adopt_dispatched(obj) return self._read_tls_record(length, **kwargs) diff --git a/pcapkit/protocols/data/application/dtls.py b/pcapkit/protocols/data/application/dtls.py index ecddbc58fe..664fd3b4ee 100644 --- a/pcapkit/protocols/data/application/dtls.py +++ b/pcapkit/protocols/data/application/dtls.py @@ -34,7 +34,7 @@ class DTLSHandshake(Handshake): :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 in this version (:issue:`2440`; see + 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`. diff --git a/pcapkit/protocols/transport/sctp.py b/pcapkit/protocols/transport/sctp.py index 58674c6655..94f725c253 100644 --- a/pcapkit/protocols/transport/sctp.py +++ b/pcapkit/protocols/transport/sctp.py @@ -337,10 +337,9 @@ class SCTP(Transport[Data_SCTP, Schema_SCTP], :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`: what an undecrypted - DTLS-wrapped NGAP PDU should report is not yet decided. It - is registered so that the PPID is *named* in the protochain rather than - reported as an unassigned number. + :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` @@ -468,8 +467,8 @@ 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 this table still maps it straight to NGAP rather - # than through DTLS. It is registered so that the PPID is + # 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 diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index a39d12c180..96ab2049c7 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -17,9 +17,36 @@ #: :mod:`tests.protocols.transport.test_tcp_http_dispatch_unit`'s port-80 header. _TCP_TO_443 = bytes.fromhex('c35001bb00000001000000015010ffff00000000') -#: Both module names the SSLv2 dissector (:issue:`2457`) may live under, so a -#: test patching them holds whichever one :meth:`TLS.read` imports. -_SSLV2_MODULES = ('pcapkit.protocols.application.ssl2', 'pcapkit.protocols.application.sslv2') +#: Module the SSLv2 dissector (:issue:`2457`) lives in, which :meth:`TLS.read +#: ` imports. +_SSLV2_MODULE = 'pcapkit.protocols.application.sslv2' + + +def _stand_in_sslv2() -> object: + """A stand-in :mod:`~pcapkit.protocols.application.sslv2` module whose + ``SSLv2`` records each delegation, so a test does not depend on the real + dissector. + + """ + import types + + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.data.application.tls import TLS as Data_TLS + + class StandInSSLv2: + alias = 'SSLv2' + name = 'Secure Sockets Layer version 2' + info = Data_TLS(content_type=ContentType.handshake, version=0x0002, + length=3, content=b'\x01\x00\x00') + _next = None + calls = [] # type: list[bytes] + + def __init__(self, data: bytes, length: int, **kwargs: object) -> None: + self.calls.append(data[:length]) + + module = types.ModuleType(_SSLV2_MODULE) + module.SSLv2 = StandInSSLv2 # type: ignore[attr-defined] + return module def _udp_to_5684(payload: bytes) -> bytes: @@ -233,11 +260,11 @@ def test_content_type_zero_is_rejected(self) -> None: 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 - the bottom of the 32-63 band :rfc:`9147` reserves, and 255 the top of - 64-255. 27 is the other side of the boundary and must *not* be - rejected -- it is :rfc:`9853`'s ``return_routability_check``, the - newest assignment -- so it is asserted here rather than left to the + 19 is the top of the Unassigned 0-19 band, 28 the bottom of 28-31, and + 32 and 63 the ends of the 32-63 band :rfc:`9147` reserves; 64-255 is + SSLv2-shaped (:class:`SSLv2HeuristicTests`). 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. @@ -246,7 +273,7 @@ def test_content_types_outside_the_assigned_range_are_rejected(self) -> None: from pcapkit.protocols.application.tls import TLS from pcapkit.utilities.exceptions import ProtocolError - for content_type in (19, 28, 32, 255): + for content_type in (19, 28, 32, 63): with self.subTest(content_type=content_type): wire = bytes([content_type]) + b'\x03\x03\x00\x04\xde\xad\xbe\xef' with self.assertRaises(ProtocolError): @@ -257,14 +284,14 @@ def test_content_types_outside_the_assigned_range_are_rejected(self) -> None: 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_pending_cid_length_tracking(self) -> None: + 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 yet. Reading ``length`` at the fixed offset every other + 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 @@ -307,12 +334,18 @@ def test_a_legacy_record_version_outside_the_protocol_range_is_rejected(self) -> value neither uses, so neither half can pass by accepting everything. """ + import sys + from unittest import mock + from pcapkit.protocols.application.dtls import DTLS from pcapkit.protocols.application.tls import TLS from pcapkit.utilities.exceptions import ProtocolError + # NOTE: a second octet other than 0x03 is SSLv2-shaped, so SSLv2 is + # blocked to reach the TLS validator. for version in (b'\x02\x03', b'\xfe\xfd'): - with self.subTest(protocol='TLS', version=version.hex()): + with self.subTest(protocol='TLS', version=version.hex()), \ + mock.patch.dict(sys.modules, {_SSLV2_MODULE: None}): wire = b'\x17' + version + b'\x00\x05hello' with self.assertRaises(ProtocolError): TLS(wire, len(wire)) @@ -369,6 +402,9 @@ def test_a_rejected_record_degrades_to_raw_rather_than_propagating(self) -> None to ``Raw`` unconditionally. """ + import sys + from unittest import mock + from pcapkit.protocols.transport.tcp import TCP from pcapkit.protocols.transport.udp import UDP @@ -382,10 +418,13 @@ def test_a_rejected_record_degrades_to_raw_rather_than_propagating(self) -> None ('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'), ) + # NOTE: SSLv2 is blocked, since the DTLS legacy version case is + # SSLv2-shaped. for name, payload, expected in tls_cases: with self.subTest(transport='TCP:443', payload=name): raw = _TCP_TO_443 + payload - with warnings.catch_warnings(): + with mock.patch.dict(sys.modules, {_SSLV2_MODULE: None}), \ + warnings.catch_warnings(): warnings.simplefilter('ignore') proto = TCP(raw, len(raw)) self.assertEqual(str(proto.protochain), expected) @@ -824,7 +863,7 @@ def test_sslv3_class_does_not_force_its_own_alias(self) -> None: 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` no longer vary with the identified + """: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. @@ -893,7 +932,7 @@ class SSLv2HeuristicTests(unittest.TestCase): """:func:`pcapkit.protocols.application.tls._looks_like_sslv2` -- the disambiguation :meth:`TLS.read ` uses to route - port-443 traffic to :class:`~pcapkit.protocols.application.ssl2.SSL2` + port-443 traffic to :class:`~pcapkit.protocols.application.sslv2.SSLv2` rather than the TLS family. """ @@ -921,26 +960,32 @@ def test_three_octet_plain_sslv2_header_is_recognised(self) -> None: def test_a_genuine_tls_record_is_not_misread_as_sslv2(self) -> None: from pcapkit.protocols.application.tls import _looks_like_sslv2 - for content_type in (0x14, 0x15, 0x16, 0x17): + for content_type in range(0x40): with self.subTest(content_type=hex(content_type)): self.assertFalse(_looks_like_sslv2(bytes([content_type, 0x03, 0x03]))) + def test_a_first_octet_no_content_type_can_take_reads_as_sslv2(self) -> None: + """64-255 opens an SSLv2 2-octet or escaped header even when a + ``0x03`` follows.""" + from pcapkit.protocols.application.tls import _looks_like_sslv2 + + for first in range(0x40, 0x100): + with self.subTest(first=hex(first)): + self.assertTrue(_looks_like_sslv2(bytes([first, 0x03, 0x03]))) + def test_too_short_to_tell_reads_as_sslv2(self) -> None: from pcapkit.protocols.application.tls import _looks_like_sslv2 self.assertTrue(_looks_like_sslv2(b'')) self.assertTrue(_looks_like_sslv2(b'\x16')) # no second octet to check - def test_sslv2_shaped_payload_degrades_to_raw_when_ssl2_is_unavailable(self) -> None: + def test_sslv2_shaped_payload_degrades_to_raw_when_sslv2_is_unavailable(self) -> None: """With SSLv2 support (:issue:`2457`) unavailable, dispatch still identifies the shape and tries to import it; the :exc:`ImportError` falls through to the TLS-family dispatch, whose own validation then rejects the payload -- degrading to :class:`~pcapkit.protocols.misc.raw.Raw` rather than crashing. - Both module names the SSLv2 dissector may live under are blocked, so - the import fails whichever one :meth:`TLS.read` imports. - """ import sys from unittest import mock @@ -949,8 +994,7 @@ def test_sslv2_shaped_payload_degrades_to_raw_when_ssl2_is_unavailable(self) -> sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header raw = _TCP_TO_443 + sslv2_shaped - unavailable = dict.fromkeys(_SSLV2_MODULES) - with mock.patch.dict(sys.modules, unavailable), warnings.catch_warnings(): + with mock.patch.dict(sys.modules, {_SSLV2_MODULE: None}), warnings.catch_warnings(): warnings.simplefilter('ignore') proto = TCP(raw, len(raw)) self.assertEqual(str(proto.protochain), 'TCP:Raw') @@ -958,43 +1002,63 @@ def test_sslv2_shaped_payload_degrades_to_raw_when_ssl2_is_unavailable(self) -> def test_delegated_parse_adopts_both_alias_and_name(self) -> None: """:meth:`SSL._adopt_dispatched` carries the delegate's :attr:`name` over alongside its :attr:`alias`, as :meth:`TLS.read` sets both when - it parses the record itself. A stand-in SSLv2 delegate - (:issue:`2457`) is installed under both module names it may live - under, so the test does not depend on the real one. + it parses the record itself. """ import sys - import types from unittest import mock - 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 - - sentinel = Data_TLS(content_type=ContentType.handshake, version=0x0002, - length=3, content=b'\x01\x00\x00') - - class StandInSSL2: - alias = 'SSLv2' - name = 'Secure Sockets Layer version 2' - info = sentinel - _next = None - - def __init__(self, data: bytes, length: int, **kwargs: object) -> None: - self.data = data[:length] - - stand_ins = {} - for name in _SSLV2_MODULES: - module = types.ModuleType(name) - module.SSL2 = module.SSLv2 = StandInSSL2 # type: ignore[attr-defined] - stand_ins[name] = module + module = _stand_in_sslv2() sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header - with mock.patch.dict(sys.modules, stand_ins): + with mock.patch.dict(sys.modules, {_SSLV2_MODULE: module}): parsed = TLS(sslv2_shaped, len(sslv2_shaped)) self.assertEqual(parsed.alias, 'SSLv2') self.assertEqual(parsed.name, 'Secure Sockets Layer version 2') - self.assertIs(parsed.info, sentinel) + self.assertIs(parsed.info, module.SSLv2.info) # type: ignore[attr-defined] + + def test_sslv2_client_hello_on_port_443_dispatches_to_sslv2(self) -> None: + """An SSLv2 ``CLIENT-HELLO`` (2-octet header, ``MSG-TYPE`` 1) on TCP + port 443 reaches the SSLv2 dissector.""" + import sys + from unittest import mock + + from pcapkit.protocols.transport.tcp import TCP + + module = _stand_in_sslv2() + body = b'\x01\x00\x02\x00\x03\x00\x00\x00\x10' + b'\x01\x00\x80' + bytes(16) + hello = (0x8000 | len(body)).to_bytes(2, 'big') + body + raw = _TCP_TO_443 + hello + with mock.patch.dict(sys.modules, {_SSLV2_MODULE: module}): + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:SSLv2') + self.assertEqual(module.SSLv2.calls, [hello]) # type: ignore[attr-defined] + + def test_every_tls_record_shape_stays_tls(self) -> None: + """A record of each content type + :data:`~pcapkit.protocols.application.ssl._WIRE_CONTENT_TYPES` accepts + parses as TLS, and every other first octet below 64 is rejected as + TLS, never delegated to SSLv2.""" + import sys + from unittest import mock + + from pcapkit.protocols.application.ssl import _WIRE_CONTENT_TYPES + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + module = _stand_in_sslv2() + with mock.patch.dict(sys.modules, {_SSLV2_MODULE: module}): + for content_type in range(0x40): + 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)) + self.assertEqual(module.SSLv2.calls, []) # type: ignore[attr-defined] class ExportTests(unittest.TestCase): diff --git a/tests/protocols/application/test_tls_dtls_roundtrip_unit.py b/tests/protocols/application/test_tls_dtls_roundtrip_unit.py index 38373281bf..1c76d67907 100644 --- a/tests/protocols/application/test_tls_dtls_roundtrip_unit.py +++ b/tests/protocols/application/test_tls_dtls_roundtrip_unit.py @@ -81,7 +81,7 @@ def _server_hello_body(extensions: 'bytes | None' = b'') -> bytes: 'alert': _tls_record(21, b'\x02\x0a'), 'change_cipher_spec': _tls_record(20, b'\x01'), 'application_data': _tls_record(23, b'hello'), - # :rfc:`9853`, the newest ContentType assignment. + # 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( diff --git a/tests/protocols/transport/test_sctp_unit.py b/tests/protocols/transport/test_sctp_unit.py index 307d6c2239..26d6fac7cc 100644 --- a/tests/protocols/transport/test_sctp_unit.py +++ b/tests/protocols/transport/test_sctp_unit.py @@ -893,8 +893,8 @@ 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 the registry still - maps it straight to NGAP rather than through DTLS, so those bytes are + 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. From 780109ee31e8914aa90a1d676970da949110e29f Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 19:18:29 -0400 Subject: [PATCH 10/11] fix(tls): drop SSLv2 delegation and trim the TLS/DTLS prose (#2441) - TLS.read no longer delegates to SSLv2: remove the guarded import, _looks_like_sslv2, SSL._adopt_dispatched and their tests. A payload that is not a TLS record falls to Raw; SSLv2 delegation lands with its own class. - Pin it: every first octet parses only as an accepted content type, and an SSLv2 hello and random segments on 443 are TCP:Raw. - Give RFC 9846's full obsoletes list; one-line note in SSL.id and ssl.rst that the SSLv2 class joins it in its own PR. - Trim the ssl/tls/dtls docstrings and their rst pages to the rationale; fix the schema's "byte-identical" and drop a timed comment in the dispatch registry test. --- .../pcapkit/protocols/application/dtls.rst | 40 +-- .../pcapkit/protocols/application/ssl.rst | 3 +- .../pcapkit/protocols/application/tls.rst | 59 ++--- pcapkit/protocols/application/dtls.py | 124 ++------- pcapkit/protocols/application/ssl.py | 248 ++++-------------- pcapkit/protocols/application/tls.py | 227 ++++------------ pcapkit/protocols/schema/application/tls.py | 15 +- .../application/test_ssl_tls_dtls_unit.py | 200 +++----------- .../protocols/test_dispatch_registry_unit.py | 2 - 9 files changed, 194 insertions(+), 724 deletions(-) diff --git a/docs/source/pcapkit/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst index 4f5504b83f..ff5c75334c 100644 --- a/docs/source/pcapkit/protocols/application/dtls.rst +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -43,35 +43,19 @@ generic ``DTLS`` alias rather than claiming a specific one. Known Limitations ----------------- -**A handshake message split across several DTLS records is not reassembled.** -DTLS fragments a handshake message when it does not fit in one datagram -(:rfc:`6347#section-4.2.3`); reassembling that would need -:class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` keyed on the -UDP 5-tuple plus epoch (:issue:`2440`). A fragment's +**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` -says whether this is the case; such a message's -:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` is kept -as that one fragment's raw octets rather than attempted as a (necessarily -wrong, since only a slice of it is present) decode. An *unfragmented* message --- ``fragment_offset == 0`` and ``fragment_length == length`` -- decodes -exactly as :class:`~pcapkit.protocols.application.tls.TLS` would, which is -the common case: a ``ClientHello`` or ``ServerHello`` that fits one datagram -never fragments. - -**DTLS 1.3's unified record header is not decoded** (:issue:`2467`). DTLS 1.3 -sends its cleartext records with the legacy ``DTLSPlaintext`` header this -module reads -- the DTLS 1.2 shape, with ``legacy_record_version`` frozen at -DTLS 1.2's value (:rfc:`9147#section-4`). The *unified header* the same -section defines for encrypted records has no content-type octet, so -:meth:`SSL._validate_tls_record ` -rejects it -- its first octet is never an accepted content type -- and it -degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. - -**Connection ID records are not decoded** (``tls12_cid``, :rfc:`9146`). Their -connection ID precedes ``length``, and its length is negotiated during the -handshake rather than carried in the record, so -:meth:`SSL._validate_tls_record ` -rejects content type 25 and the record degrades to +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 diff --git a/docs/source/pcapkit/protocols/application/ssl.rst b/docs/source/pcapkit/protocols/application/ssl.rst index c44e49b423..f216a58580 100644 --- a/docs/source/pcapkit/protocols/application/ssl.rst +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -14,7 +14,8 @@ family [*]_ -- the SSL versions, Transport Layer Security and Datagram TLS: * :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. +concrete subclasses, so a lookup keyed on ``SSL`` matches any of them. The +SSLv2 class joins it in its own PR. The TLS record header (:rfc:`9846#section-5.1`) is a :class:`~pcapkit.const.tls.content_type.ContentType`, a legacy record version diff --git a/docs/source/pcapkit/protocols/application/tls.rst b/docs/source/pcapkit/protocols/application/tls.rst index 0168bc055c..4162411c7b 100644 --- a/docs/source/pcapkit/protocols/application/tls.rst +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -8,8 +8,8 @@ TLS/SSL - Transport Layer Security :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:`8446` and :rfc:`5246`) -- at the -record layer, plus +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 @@ -70,46 +70,25 @@ so the alias still follows the wire. Dispatch never constructs one. Decoded Fields -------------- -* The record header -- content type, legacy version, length -- in full, - regardless of encryption: this part is always in the clear. -* For a **cleartext** ``handshake``, ``alert``, or ``change_cipher_spec`` - record -- i.e. one that has not yet started carrying ciphertext, decided - per-record by - :meth:`SSL._tls_content_is_cleartext `: - - * ``ClientHello``/``ServerHello`` down to their cipher suite(s), - compression method(s), and extension list, TYPE+LENGTH framed (extension - bodies are not decoded). - * Every other handshake message (``Certificate``, ``ServerKeyExchange``, - ``ClientKeyExchange``, etc.) at the message-type/length level only: its - body is kept undecoded. ``Finished`` is always ciphertext -- there is no - cleartext ``Finished`` -- so it is never decoded even at this level and - always stays opaque bytes. - * ``Alert`` and ``ChangeCipherSpec`` records in full. - -* An **encrypted** ``handshake``, ``alert``, or ``change_cipher_spec`` - record -- which is the common case for TLS 1.2 traffic once the handshake - completes -- plus ``application_data`` and every other content type, as - opaque :obj:`bytes`. - -The record header is visible on the wire regardless of encryption, but the -record *content* is not: once the handshake has derived keys, a -``handshake``/``alert``/``change_cipher_spec`` record may carry ciphertext -indistinguishable at the header level from its cleartext counterpart, and -only the content stays undecoded in that case -- see -:meth:`SSL._tls_content_is_cleartext ` -for how each case is told apart. Decrypting ``application_data`` is out of -scope. +* 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 so decodes -as :class:`~pcapkit.protocols.misc.raw.Raw` rather than as the record it is the -start of. A record may be up to 16 KiB against a typical 1460-octet MSS, so -this is routine rather than rare. See -:meth:`SSL._validate_tls_record ` -for why rejecting such a fragment is the honest answer at this layer. +---------------- + +**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 diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py index 24b8a8b837..21555b084f 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -7,53 +7,27 @@ :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`). The -epoch and sequence number in the record header, which set DTLS apart from -:class:`~pcapkit.protocols.application.tls.TLS`, are declared in -:class:`~pcapkit.protocols.schema.application.dtls.DTLS` and read here; the -handshake-message fragment header is read by the handshake decoding shared -with TLS in :class:`~pcapkit.protocols.application.ssl.SSL`. +1.2 (:rfc:`6347`), and DTLS 1.3's legacy-format records (:rfc:`9147`). :attr:`UDP.__proto__ ` binds -this class at port 5684; its own :meth:`read` parses the ``DTLSPlaintext`` -record header, then sets :attr:`~DTLS.alias` according to -the record's legacy version -- mirroring -:class:`~pcapkit.protocols.link.arp.ARP`'s own dynamic dispatch over -RARP/DRARP/InARP by operation code, and -:class:`~pcapkit.protocols.application.tls.TLS`'s own dispatch over its -family. +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 several DTLS records is not reassembled.** -DTLS fragments a handshake message when it does not fit in one datagram -(:rfc:`6347#section-4.2.3`); reassembling that would need -:class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` keyed on the -UDP 5-tuple plus epoch (:issue:`2440`). A fragment's +**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` -says whether this is the case; such a message's -:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` is kept -as that one fragment's raw octets rather than attempted as a (necessarily -wrong, since only a slice of it is present) decode. An *unfragmented* message --- ``fragment_offset == 0`` and ``fragment_length == length`` -- decodes -exactly as :class:`~pcapkit.protocols.application.tls.TLS` would, which is -the common case: a ``ClientHello`` or ``ServerHello`` that fits one datagram -never fragments. - -**DTLS 1.3's unified record header is not decoded** (:issue:`2467`). DTLS 1.3 -sends its cleartext records with the legacy ``DTLSPlaintext`` header this -module reads -- the DTLS 1.2 shape, with ``legacy_record_version`` frozen at -DTLS 1.2's value (:rfc:`9147#section-4`). The *unified header* the same -section defines for encrypted records has no content-type octet, so -:meth:`SSL._validate_tls_record ` -rejects it -- its first octet is never an accepted content type -- and it -degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. - -**Connection ID records are not decoded** (``tls12_cid``, :rfc:`9146`). Their -connection ID precedes ``length``, and its length is negotiated during the -handshake rather than carried in the record, so -:meth:`SSL._validate_tls_record ` -rejects content type 25 and the record degrades to +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 @@ -98,22 +72,13 @@ class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): Note: The subpackage does not track the dispatch tier: DTLS is dispatched from :attr:`UDP.__proto__ ` - at port 5684, the ``coaps`` (CoAP over DTLS, :rfc:`7252`) service name - IANA assigns there. It is not the only such port -- IANA registers - DTLS-borne services at several, among them 853 (``domain-s``), 4740 - (``ipfixs``), 5349 (``stuns``/``turns``), 6514 (``syslog-tls``) and - 10161/10162 (``snmpdtls``) -- so binding 5684 alone is a choice about - what is common on the wire rather than an exhaustive reading of the - registry, and plenty of deployments negotiate DTLS on a dynamic port - no registry can name. Binding another is one - :func:`pcapkit.foundation.registry.protocols.register_udp` call. + at port 5684 (``coaps``, :rfc:`7252`) only, though IANA registers + other DTLS-borne services; bind more with + :func:`pcapkit.foundation.registry.protocols.register_udp`. """ - #: Resolved alias once :meth:`read` has identified the concrete tier -- - #: e.g. ``'DTLS 1.2'``. The class name itself until then, same as the - #: unversioned default every :class:`~pcapkit.protocols.protocol.ProtocolBase` - #: falls back to. Mirrors :attr:`ARP._acnm `. + #: 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"] ########################################################################## @@ -158,12 +123,6 @@ def id(cls) -> 'tuple[Literal["DTLS"]]': # type: ignore[override] def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': """Read Datagram Transport Layer Security (DTLS). - Parses the record and identifies its concrete tier -- DTLS 1.0, DTLS - 1.2, or DTLS 1.3's legacy-format records -- setting :attr:`alias` - accordingly, mirroring :meth:`TLS.read - `'s own dispatch over its - family. - Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. @@ -190,13 +149,7 @@ def make(self, data: 'Optional[bytes]' = None, payload: 'bytes | ProtocolBase | Schema' = b'', **kwargs: 'Any') -> 'Schema_DTLS': - """Make (construct) packet data. - - Shared by every tier in the DTLS family -- DTLS 1.0 through 1.3's - legacy-format records wrap the identical record header and content - shapes, so there is nothing version-specific for this to dispatch - on; the ``version`` argument is simply written through to the - record's legacy version field. + """Make (construct) packet data, for every tier: ``version`` is written as is. Args: content_type: Record content type. @@ -275,11 +228,7 @@ def _make_data(cls, data: 'Data_DTLS') -> 'dict[str, Any]': # type: ignore[over 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, the ``DTLSPlaintext`` form - [:rfc:`9147#section-4`] -- the full header this module reads; - ``DTLSCiphertext``, defined in that same section, is instead the - on-the-wire *compact* header an encrypted record may use once epoch 0 - has passed, which this module does not decode: + Structure of DTLS record layer [:rfc:`9147#section-4`]: .. code-block:: text @@ -292,21 +241,11 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> opaque fragment[DTLSPlaintext.length]; } DTLSPlaintext; - Parses the record first, then identifies the concrete tier from its - legacy version and sets :attr:`alias` accordingly -- mirroring - :meth:`ARP.read `'s own - ``self._acnm = ...`` dispatch by operation code. DTLS 1.3's - legacy-format records freeze theirs at DTLS 1.2's value -- or DTLS - 1.0's on an initial ``ClientHello`` (:rfc:`9147#section-4`) -- and - are told apart by peeking the ``supported_versions`` extension - (:meth:`SSL._tls_negotiates `). - Otherwise DTLS 1.0 (``0xFEFF``) and DTLS 1.2 (``0xFEFD``) are named - directly by the record's legacy version, and any other ``0xFExx`` - value keeps the generic ``'DTLS'`` alias rather than claiming a tier. - A legacy version outside ``0xFExx`` never reaches this - dispatch at all: :meth:`SSL._validate_tls_record - `, called - first, already raises for it. + 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. @@ -322,17 +261,6 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> the end of the buffer -- or if the record fragment is malformed for its content type. - Notes: - A ``handshake``, ``alert``, or ``change_cipher_spec`` record is - decoded structurally only at epoch 0, the cleartext initial - handshake; at any later epoch the same content types carry - ciphertext, whose octets are kept as-is -- an encrypted - ``alert`` (including the ``close_notify`` that ends nearly every - TLS 1.2 connection) is the same shape of problem as an encrypted - ``handshake``, just one epoch later than the initial flight. See - :meth:`SSL._tls_content_is_cleartext - `. - """ schema = self.__header__ available = length if length is not None else len(self) diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index 1027f2025c..516f7f449d 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -34,7 +34,6 @@ from pcapkit.const.tls.content_type import ContentType as Enum_ContentType from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType -from pcapkit.corekit.protochain import ProtoChain from pcapkit.protocols.application.application import Application from pcapkit.protocols.data.application.tls import Alert as Data_Alert from pcapkit.protocols.data.application.tls import ChangeCipherSpec as Data_ChangeCipherSpec @@ -42,7 +41,6 @@ from pcapkit.protocols.data.application.tls import Extension as Data_Extension from pcapkit.protocols.data.application.tls import Handshake as Data_Handshake from pcapkit.protocols.data.application.tls import ServerHello as Data_ServerHello -from pcapkit.protocols.misc.null import NoPayload from pcapkit.protocols.protocol import _PT, _ST from pcapkit.utilities.exceptions import ProtocolError @@ -61,31 +59,14 @@ #: 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, i.e. every value IANA's ``TLS -#: ContentType`` registry assigns. Written out rather than taken as "every -#: member :class:`~pcapkit.const.tls.content_type.ContentType` names" for two -#: reasons: :attr:`~pcapkit.const.tls.content_type.ContentType.invalid` is a -#: named member that is *not* a wire value -- :rfc:`9846#section-5.1` defines -#: ``invalid(0)`` as a sentinel to be rejected, and IANA keeps 0 inside its -#: Unassigned 0-19 band -- and iterating the registry would also be a moving -#: target, since a lookup of an unassigned value mints a member rather than -#: raising. An allowlist is what makes this a *decision* about the wire rather -#: than a restatement of whatever the registry happens to hold. +#: 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. #: -#: :attr:`~pcapkit.const.tls.content_type.ContentType.tls12_cid` (25, -#: :rfc:`9146`) is deliberately left out. Every other type here keeps -#: ``length`` at the fixed offset :class:`~pcapkit.protocols.schema.application.tls.TLS`/ -#: :class:`~pcapkit.protocols.schema.application.dtls.DTLS` declare it at, but -#: a Connection ID record inserts the CID *before* ``length`` -#: (:rfc:`9146#section-4`), and the CID's own length is negotiated out of band -#: during the handshake -- it is not on the wire of the record itself. Reading -#: ``length`` at the fixed offset for a type-25 record decodes the tail of the -#: CID as the length field, fabricating a fragment that was never on the wire -#: (an 8-octet CID makes an 8-octet fragment read back as ``length=17``). -#: Parsing it correctly needs the negotiated CID length per connection, which -#: the stateless, per-packet schema read has nowhere to keep, so type 25 is -#: excluded here and falls back to -#: :class:`~pcapkit.protocols.misc.raw.Raw` instead of being decoded wrong. +#: ``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, @@ -96,19 +77,13 @@ Enum_ContentType.return_routability_check, }) -#: The IANA ``supported_versions`` extension type (:rfc:`9846#section-4.3.1`). -#: TLS 1.3 and DTLS 1.3 negotiate the *real* version here rather than in the -#: record header's own legacy version, which both freeze at their immediate -#: predecessor's value for backward compatibility -- see :data:`TLS13_VERSION` -#: and :data:`DTLS13_VERSION`. +#: 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 wire value a ``supported_versions`` extension carries for TLS 1.3 -#: (:rfc:`9846#section-4.3.1`). Never the record layer's own -#: ``legacy_version``, which TLS 1.3 freezes at TLS 1.2's ``0x0303``. +#: The ``supported_versions`` value for TLS 1.3 (:rfc:`9846#section-4.3.1`). TLS13_VERSION = 0x0304 -#: Same, for DTLS 1.3 (:rfc:`9147#section-5.3`). The record layer stays -#: frozen at DTLS 1.2's ``0xFEFD``. +#: The ``supported_versions`` value for DTLS 1.3 (:rfc:`9147#section-5.3`). DTLS13_VERSION = 0xFEFC @@ -150,10 +125,8 @@ def _negotiated_versions(message: 'Any') -> 'tuple[int, ...]': 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 never raises on a short buffer -- ``b'ab'[0:4]`` is ``b'ab'`` - -- so a truncated handshake message would otherwise parse a few octets - short rather than fail. This is what makes each caller's spec-shaped slice - actually bounds-checked. + Plain slicing silently returns fewer octets, which would let a truncated + message parse short rather than fail. Args: data: Buffer being read. @@ -178,14 +151,9 @@ def _take(data: 'bytes', pos: 'int', size: 'int', *, what: 'str', alias: 'str') def _tls_handshake_tiles(fragment: 'bytes') -> 'bool': """Whether ``fragment`` is tiled exactly by handshake-message headers. - A ``handshake`` record's fragment is cleartext only until the handshake - derives keys; after that a record still typed ``handshake`` (a ``Finished``, - a post-handshake ``NewSessionTicket`` or ``KeyUpdate``) carries ciphertext, - and nothing in the cleartext header says which it is. What does distinguish - them is self-consistency: a cleartext fragment is a whole number of - messages, each a 4-octet ``msg_type``/``length`` header followed by exactly - ``length`` octets, so walking those headers lands on the fragment's last - octet exactly. Ciphertext is pseudorandom and overwhelmingly does not. + 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. @@ -194,18 +162,9 @@ def _tls_handshake_tiles(fragment: 'bytes') -> 'bool': Whether the walk lands exactly on the end of ``fragment``. Notes: - This is a heuristic, and an imperfect one in both directions. A - ciphertext fragment whose octets happen to tile is still taken for - cleartext -- telling the two apart needs the session keys, which a - passive dissector does not have. And a genuine cleartext handshake - message that :rfc:`9846#section-5.1` has split across more than one - TLS record does *not* tile within a single record either, so this - walk fails it the same way it fails ciphertext; the "always tiles" - case holds only for a message that fits in one record, which is the - common one. Both misses land on the same, conservative side, though: - this method is consulted only to decide whether to decode, and - keeping either case's octets opaque loses nothing, while mis-decoding - ciphertext fabricates messages. + 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 @@ -233,7 +192,7 @@ class SSL(Application[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract @classmethod def id(cls) -> 'tuple[Literal["TLS"], Literal["DTLS"]]': - """Index ID of the protocol. + """Index ID of the protocol. The SSLv2 class joins it in its own PR. Returns: Index ID of the protocol. @@ -248,19 +207,12 @@ def id(cls) -> 'tuple[Literal["TLS"], Literal["DTLS"]]': 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 TCP port 443 (or UDP - port 5684), and once a connection is past its handshake most of those - are mid-stream ciphertext continuations with no record header in them - at all -- not an edge case, the common case. Without this check, any - content-type octet and legacy version decode as *some* record: - :class:`~pcapkit.corekit.enum.EnumRegistry`'s ``_missing_`` mints an - ``Unassigned`` pseudo-member for a value no :class:`~pcapkit.const.tls.content_type.ContentType` - names rather than refusing it, and a declared :attr:`~pcapkit.protocols.schema.application.tls.TLS.length` - longer than what is actually left is silently zero-padded out to that - length by :meth:`FieldBase.unpack `'s - truncated-capture tolerance -- fabricating record content from octets - that were never on the wire, rather than signalling that this was not - a record to begin with. + 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. @@ -276,17 +228,9 @@ def _validate_tls_record(self, schema: 'Any', *, available: 'int', dtls: 'bool') of accepting a fabricated one. Note: - **A record split across two TCP segments is rejected**, since its - first segment carries a header whose declared ``length`` genuinely - does run past the end of that segment. A TLS record may be up to - 16 KiB (:rfc:`9846#section-5.1`) against a typical 1460-octet MSS, - so this is routine rather than rare -- a post-quantum - ``ClientHello`` alone exceeds one segment. Accepting it would need - the record reassembled across the stream first, which is - :class:`~pcapkit.foundation.reassembly.tcp.TCP`'s job and not - something this layer can see; until a record arrives whole, - degrading to :class:`~pcapkit.protocols.misc.raw.Raw` is the honest - answer, because the fragment this layer holds is not a record. + 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 @@ -313,19 +257,8 @@ def _validate_tls_record(self, schema: 'Any', *, available: 'int', dtls: 'bool') def _tls_negotiates(self, schema: 'Any', target: 'int', *, dtls: 'bool') -> 'bool': """Whether this record's first handshake message negotiates ``target``. - TLS 1.3 and DTLS 1.3 freeze the record layer's own legacy version at - their predecessor's value and negotiate the real version through a - ``supported_versions`` extension on the ``ClientHello``/``ServerHello`` - instead (:data:`TLS13_VERSION`, :data:`DTLS13_VERSION`). A dispatcher - picking a concrete version tier for an ambiguous legacy version - consults this to tell the two apart. - - This is used from a dispatcher's own version guess, *before* the real - parse that follows: any failure to decode here -- a malformed - message, a non-handshake record, ciphertext that happens not to tile - -- is reported as "no", never propagated, since the real parse the - dispatcher goes on to run is what reports a genuine error, not this - best-effort peek. + 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. @@ -349,70 +282,22 @@ def _tls_negotiates(self, schema: 'Any', target: 'int', *, dtls: 'bool') -> 'boo body = getattr(content[0], 'body', None) return target in _negotiated_versions(body) - def _adopt_dispatched(self, obj: 'Any') -> 'Any': - """Adopt a dispatched tier's parse result as this dispatcher's own. - - Used where a version-dispatching ``read()`` hands the payload to a - different class -- :meth:`TLS.read ` - delegating an SSLv2-shaped payload -- mirroring - :meth:`HTTP.read `'s - own adoption of whichever versioned object it constructed: the - dispatcher's own :attr:`alias ` - and :attr:`name ` both - become the delegate's -- the same pair ``read()`` sets when it parses - the record itself -- and a next layer the delegate's own parse - already decoded is carried over rather than decoded twice. - - Args: - obj: The concrete tier's own, already-parsed instance. - - Returns: - ``obj``'s parsed data model, which becomes this dispatcher's own - :attr:`~pcapkit.protocols.protocol.ProtocolBase.info`. - - """ - self._alias = obj.alias # pylint: disable=attribute-defined-outside-init - self._name = obj.name # pylint: disable=attribute-defined-outside-init - next_ = getattr(obj, '_next', None) - if next_ is not None and not isinstance(next_, NoPayload): - self._next = next_ # pylint: disable=attribute-defined-outside-init - self._protos = ProtoChain(self.__class__, self.alias, basis=next_.protochain) # pylint: disable=attribute-defined-outside-init - return obj.info - def _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. - Three content types -- ``handshake``, ``alert``, and - ``change_cipher_spec`` -- carry a record format that is genuine only - before keys are derived; once they are, a record still typed the same - way carries ciphertext, with nothing in the cleartext record header to - say which it is. Decoding the ciphertext case structurally either - fabricates content out of pseudorandom octets or fails and drops the - whole record to :class:`~pcapkit.protocols.misc.raw.Raw`, losing the - record header that *was* in the clear -- the bug this method exists to - avoid for all three types rather than just ``handshake``. - - The two protocols answer this differently because DTLS gives a direct - answer and TLS does not: - - * **DTLS** states it in the record header, for every content type: - the epoch counts cipher changes (:rfc:`9147#section-4.2`), so epoch - 0 is exactly the cleartext initial handshake and any later epoch is - encrypted. - * **TLS** has no such field, so each type falls back to its own - self-consistency check instead: - - * ``handshake`` -- :func:`_tls_handshake_tiles` asks whether the - fragment is a whole number of messages. - * ``alert`` -- a genuine cleartext alert is always exactly 2 octets - (``level`` + ``description``); an encrypted one can never land on - exactly 2, because of the MAC/tag overhead encryption adds. - * ``change_cipher_spec`` -- the same reasoning, at exactly 1 octet. - - Any other content type is reported as *not* cleartext -- a miss lands - on the conservative side, same as :func:`_tls_handshake_tiles` -- so a - content type without a branch here fails closed. + ``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. @@ -462,21 +347,9 @@ def _read_tls_content(self, content_type: 'Enum_ContentType', fragment: 'bytes', ProtocolError: If ``fragment`` is malformed for ``content_type``. Notes: - None of ``handshake``, ``alert``, or ``change_cipher_spec`` are - necessarily *cleartext* just because of their content type. - TLS 1.2's ``Finished`` and any renegotiation handshake travel as - ciphertext under ``handshake`` once keys are derived, with no - flag in the cleartext header to say so -- and TLS 1.3's - post-handshake messages (``NewSessionTicket``, ``KeyUpdate``) are - not under this type at all, since TLS 1.3's outer record type is - always ``application_data`` once encryption starts, and - ``KeyUpdate`` has no TLS 1.2 equivalent to begin with. An - encrypted TLS 1.2 ``alert`` -- including the ``close_notify`` - that ends nearly every connection -- or a ``change_cipher_spec`` - sent mid-renegotiation is the same shape of problem under those - two types. Every one of the three is kept as opaque :obj:`bytes` - rather than decoded when it is not cleartext, which is what - :meth:`_tls_content_is_cleartext` decides. + ``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: @@ -527,10 +400,7 @@ def _read_tls_handshake_messages(self, fragment: 'bytes', *, dtls: 'bool') -> 'tuple[Any, ...]': """Decode a ``handshake`` record fragment into its handshake messages. - A TLS record may coalesce several handshake messages (e.g. - ``Certificate``, ``ServerKeyExchange`` and ``ServerHelloDone`` in one - record), so this reads however many fit in ``fragment`` rather than - exactly one. + A record may coalesce several handshake messages. Args: fragment: Record fragment, i.e. one or more concatenated @@ -548,16 +418,9 @@ def _read_tls_handshake_messages(self, fragment: 'bytes', *, ``fragment``. Notes: - A ``ClientHello``/``ServerHello`` body is decoded into - :class:`~pcapkit.protocols.data.application.tls.ClientHello`/ - :class:`~pcapkit.protocols.data.application.tls.ServerHello` - only when the whole message is present -- always true for TLS, - and true for DTLS only when the message is not fragmented across - several records. A DTLS message that is split across records is - not reassembled (:issue:`2440`), so its body is - kept as the single fragment's raw octets regardless of - ``msg_type``. Every other ``msg_type`` is message-TYPE-level - framing only: its body is kept undecoded (:issue:`2437`). + 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 \ @@ -672,11 +535,8 @@ def _read_tls_client_hello(self, body: 'bytes', *, dtls: 'bool') -> 'Data_Client Extension extensions<7..2^16-1>; } ClientHello; - DTLS's ``ClientHello`` is identical except for one insertion: a - length-prefixed ``cookie`` directly after ``legacy_session_id`` and - before ``cipher_suites`` [:rfc:`6347#section-4.2.1`; - :rfc:`9147#section-5.3`'s ``legacy_cookie`` carries it forward - unchanged], which TLS's ``ClientHello`` does not carry at all: + DTLS inserts a ``cookie`` after ``legacy_session_id`` + [:rfc:`6347#section-4.2.1`, :rfc:`9147#section-5.3`]: .. code-block:: text @@ -884,9 +744,7 @@ def _read_tls_extensions(self, data: 'bytes') -> 'tuple[Data_Extension, ...]': opaque extension_data<0..2^16-1>; } Extension; - An extension's own body is not decoded (:issue:`2437`): it is kept - as :attr:`~pcapkit.protocols.data.application.tls.Extension.data`, - undecoded. + An extension's body is kept undecoded (:issue:`2437`). Args: data: The extension list, i.e. one or more concatenated diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index a1a188f4e3..70ca0ea43d 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -9,61 +9,36 @@ :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:`8446` and :rfc:`5246`). :attr:`TCP.__proto__ +which obsoletes :rfc:`5077`, :rfc:`5246`, :rfc:`6961`, :rfc:`7627`, +:rfc:`8422` and :rfc:`8446`). :attr:`TCP.__proto__ ` binds :class:`TLS` at port -443; its own :meth:`~TLS.read` parses the (always wire-identical) record -header, then sets :attr:`~TLS.alias` (and, for SSLv3, :attr:`~TLS.name`) -according to the record's legacy version -- 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. Where the payload is SSLv2-shaped instead, -:meth:`~TLS.read` delegates to -:class:`~pcapkit.protocols.application.sslv2.SSLv2`. +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 -- content type, legacy version, length -- in full, - regardless of encryption: this part is always in the clear. -* For a **cleartext** ``handshake``, ``alert``, or ``change_cipher_spec`` - record -- i.e. one that has not yet started carrying ciphertext, decided - per-record by - :meth:`SSL._tls_content_is_cleartext `: - - * ``ClientHello``/``ServerHello`` down to their cipher suite(s), - compression method(s), and extension list, TYPE+LENGTH framed (extension - bodies are not decoded). - * Every other handshake message (``Certificate``, ``ServerKeyExchange``, - ``ClientKeyExchange``, etc.) at the message-type/length level only: its - body is kept undecoded. ``Finished`` is always ciphertext -- there is no - cleartext ``Finished`` -- so it is never decoded even at this level and - always stays opaque bytes. - * ``Alert`` and ``ChangeCipherSpec`` records in full. - -* An **encrypted** ``handshake``, ``alert``, or ``change_cipher_spec`` - record -- which is the common case for TLS 1.2 traffic once the handshake - completes -- plus ``application_data`` and every other content type, as - opaque :obj:`bytes`. - -The record header is visible on the wire regardless of encryption, but the -record *content* is not: once the handshake has derived keys, a -``handshake``/``alert``/``change_cipher_spec`` record may carry ciphertext -indistinguishable at the header level from its cleartext counterpart, and -only the content stays undecoded in that case -- see -:meth:`SSL._tls_content_is_cleartext ` -for how each case is told apart. Decrypting ``application_data`` is out of -scope. +* 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 so decodes -as :class:`~pcapkit.protocols.misc.raw.Raw` rather than as the record it is the -start of. A record may be up to 16 KiB against a typical 1460-octet MSS, so -this is routine rather than rare. See -:meth:`SSL._validate_tls_record ` -for why rejecting such a fragment is the honest answer at this layer. +**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 @@ -88,23 +63,11 @@ __all__ = ['TLS', 'SSLv3'] -#: Legacy record-version major octet TLS/SSLv3+ records carry (``0x03xx``, -#: SSLv3 through the TLS 1.3 wire value). Mirrors -#: :data:`~pcapkit.protocols.application.ssl._TLS_VERSION_MAJOR`, which this -#: module does not import -- that constant is private to -#: :mod:`~pcapkit.protocols.application.ssl` -- but the value the two check -#: is the same one by definition. -_TLS_VERSION_MAJOR = 0x03 - -#: Legacy record version SSLv3 carries -- the one tier in this family with a -#: different full :attr:`~TLS.name` as well as a different :attr:`~TLS.alias`. +#: Legacy record version SSLv3 carries, the one tier with its own :attr:`~TLS.name`. _SSLV3_VERSION = 0x0300 -#: Legacy record version to alias, for every tier the record layer's own -#: version names directly. ``0x0303`` reads as TLS 1.2 unless the -#: ``supported_versions`` peek finds TLS 1.3 (see -#: :data:`_TLS13_LEGACY_VERSIONS`). A version not listed here keeps the -#: generic ``'TLS'`` alias. +#: 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', @@ -116,43 +79,6 @@ #: ``0x0301`` on an initial ``ClientHello`` (:rfc:`9846#section-5.1`). _TLS13_LEGACY_VERSIONS = frozenset({0x0301, 0x0303}) -#: Exclusive upper bound of a TLS content type that :func:`_looks_like_sslv2` -#: accepts: the first octets SSLv2's padded 3-octet header also uses (top bits -#: ``00``). IANA assigns no content type above it, and :rfc:`9443` reserves -#: 64-255 for other protocols multiplexed on the same port. -_TLS_FIRST_OCTET_LIMIT = 0x40 - - -def _looks_like_sslv2(data: 'bytes') -> 'bool': - """Whether ``data`` opens with an SSLv2 record header rather than a TLS one. - - A TLS record header (:rfc:`9846#section-5.1`) opens with a content type - and the legacy version's major octet, :data:`_TLS_VERSION_MAJOR`. An - SSLv2 header (draft-hickman-netscape-ssl-00, "SSL Record Header") has no - content type: a first octet with the top bit set opens a 2-octet header, - one with top bits ``01`` an escaped 3-octet header, and one with top bits - ``00`` a padded 3-octet header whose second octet is the low half of the - record length (see :mod:`~pcapkit.protocols.application.sslv2`). - - ``data`` is read as TLS when its first octet is below - :data:`_TLS_FIRST_OCTET_LIMIT` and its second is - :data:`_TLS_VERSION_MAJOR`, whether or not the content type is one - :meth:`SSL._validate_tls_record - ` accepts, so - that it, not SSLv2, rejects the others. Everything else reads as SSLv2, - including a buffer too short to hold both octets. - - Args: - data: The payload, from its first octet. - - Returns: - :data:`False` when ``data`` opens with a TLS record header; - :data:`True` otherwise. - - """ - return not (len(data) >= 2 and data[0] < _TLS_FIRST_OCTET_LIMIT - and data[1] == _TLS_VERSION_MAJOR) - 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. @@ -171,17 +97,10 @@ class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): """ - #: Name of corresponding protocol -- dynamic only for SSLv3, the one - #: tier in this family with a genuinely different full name; every other - #: tier keeps this default. Mirrors - #: :attr:`ARP._name `. + #: 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 - #: Resolved alias once :meth:`read` has identified the concrete tier -- - #: e.g. ``'SSLv3'`` or ``'TLS 1.2'``. The class name itself until then, - #: same as the unversioned default every - #: :class:`~pcapkit.protocols.protocol.ProtocolBase` falls back to. - #: Mirrors :attr:`ARP._acnm `. + #: 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"] ########################################################################## @@ -216,32 +135,15 @@ def id(cls) -> 'tuple[Literal["TLS"]]': # 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: a dynamically dispatched - record never becomes a different *class*, only a different - :attr:`alias`, so a genuine TLS 1.2 record must not stop - matching a lookup keyed on this value just because *some* - instance of this class happened to parse as, say, SSLv3. - Inspect :attr:`alias`, or the type of :attr:`info - `, to tell the - identified tier apart instead. SSLv2 is not among them either: - :meth:`read` may still delegate to it when the payload is shaped - that way (see :func:`_looks_like_sslv2`), but doing so hands back - an :class:`~pcapkit.protocols.application.sslv2.SSLv2` instance, - which carries its own, distinct :meth:`id`. + 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') -> 'Any': - """Read Transport Layer Security (TLS), or whatever port 443 actually carries. - - Where the payload is SSLv2-shaped instead (:func:`_looks_like_sslv2`), - delegates to :class:`~pcapkit.protocols.application.sslv2.SSLv2` and - adopts its parse. Otherwise parses the TLS/SSLv3+ record itself via - :meth:`_read_tls_record`, which also sets :attr:`alias` (and, for - SSLv3, :attr:`name`) according to the record's legacy version -- - :data:`self ` throughout, never a distinct instance. + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': + """Read Transport Layer Security (TLS). Args: length: Length of packet data. @@ -252,25 +154,9 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': Raises: ProtocolError: If the record cannot be a genuine TLS/SSLv3+ - record -- see :meth:`_read_tls_record` -- or, where - delegated, if :class:`~pcapkit.protocols.application.sslv2.SSLv2`'s - own :meth:`read ` - fails. + record -- see :meth:`_read_tls_record`. """ - if length is None: - length = len(self) - - if _looks_like_sslv2(self._data[:length]): - try: - from pcapkit.protocols.application.sslv2 import SSLv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel - except ImportError: - pass # SSLv2 support (#2457) is unavailable -- fall through - # to the TLS/SSLv3+ parse below. - else: - obj = SSLv2(self._data, length, **kwargs) - return self._adopt_dispatched(obj) - return self._read_tls_record(length, **kwargs) def make(self, @@ -283,12 +169,7 @@ def make(self, data: 'Optional[bytes]' = None, payload: 'bytes | ProtocolBase | Schema' = b'', **kwargs: 'Any') -> 'Schema_TLS': - """Make (construct) packet data. - - Shared by every tier in the TLS family -- SSLv3 through TLS 1.3 wrap - the identical record header and content shapes, so there is nothing - version-specific for this to dispatch on; the ``version`` argument is - simply written through to the record's legacy version field. + """Make (construct) packet data, for every tier: ``version`` is written as is. Args: content_type: Record content type. @@ -372,22 +253,11 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' opaque fragment[TLSPlaintext.length]; } TLSPlaintext; - Parses the record first, then identifies the concrete tier from its - legacy version and sets :attr:`alias` (and, for SSLv3, :attr:`name`) - accordingly -- mirroring :meth:`ARP.read `'s - own ``self._acnm = ...`` dispatch by operation code. TLS 1.3 records - carry a legacy version of ``0x0303`` -- or ``0x0301`` on an initial - ``ClientHello`` (:rfc:`9846#section-5.1`) -- and are told apart by - peeking the ``supported_versions`` extension - (:meth:`SSL._tls_negotiates `). - Otherwise SSLv3 (``0x0300``), TLS 1.0 (``0x0301``), TLS 1.1 - (``0x0302``) and TLS 1.2 (``0x0303``) are named directly by the - record's legacy version. Any other ``0x03xx`` value names no known - tier and keeps the generic ``'TLS'`` alias rather than claiming one. - Any legacy version outside SSLv3+'s ``0x03xx`` range - never reaches this dispatch at all: :meth:`SSL._validate_tls_record - `, called - first, already raises for it. + 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. @@ -433,20 +303,11 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' class SSLv3(TLS, schema=Schema_TLS, data=Data_TLS): - """This class implements Secure Sockets Layer version 3, for explicit, - manual construction only. - - :meth:`TLS.read `'s own dispatch never constructs an instance - of this class -- a record identified as SSLv3 stays a :class:`TLS` - instance throughout, with :attr:`~TLS.alias` and :attr:`~TLS.name` set - accordingly. This class exists for a caller who already knows they want - to build or represent something as specifically SSLv3, mirroring - :class:`~pcapkit.protocols.link.arp.InARP`'s relationship to - :class:`~pcapkit.protocols.link.arp.ARP`: it narrows :meth:`id` only, and - inherits :meth:`~TLS.read` unparsed -- parsing SSLv3-shaped data through - this class sets :attr:`~TLS.alias` to ``'SSLv3'`` the same way parsing it - through :class:`TLS` would, since the wire format and the dispatch logic - are both :class:`TLS`'s. + """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. """ diff --git a/pcapkit/protocols/schema/application/tls.py b/pcapkit/protocols/schema/application/tls.py index 74c50f8260..61feeb04a6 100644 --- a/pcapkit/protocols/schema/application/tls.py +++ b/pcapkit/protocols/schema/application/tls.py @@ -20,17 +20,10 @@ class TLS(Schema): """Header schema for TLS record layer packet. - The record header is byte-identical in shape to :class:`DTLS - `'s, minus the epoch and - sequence number DTLS inserts before ``length`` (:rfc:`9846#section-5.1`). - :attr:`fragment` is kept as the raw record fragment; everything it may - hold -- a list of handshake messages, an alert, change-cipher-spec, or - opaque application data -- is decoded in - :meth:`TLS.read ` by the - shared helpers on - :class:`~pcapkit.protocols.application.ssl.SSL`, rather than here, so that - :class:`~pcapkit.protocols.schema.application.dtls.DTLS` can reuse the same - decoding logic over its own (fragment-headered) handshake messages. + 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. """ diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index 96ab2049c7..3d6bad1592 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -17,38 +17,6 @@ #: :mod:`tests.protocols.transport.test_tcp_http_dispatch_unit`'s port-80 header. _TCP_TO_443 = bytes.fromhex('c35001bb00000001000000015010ffff00000000') -#: Module the SSLv2 dissector (:issue:`2457`) lives in, which :meth:`TLS.read -#: ` imports. -_SSLV2_MODULE = 'pcapkit.protocols.application.sslv2' - - -def _stand_in_sslv2() -> object: - """A stand-in :mod:`~pcapkit.protocols.application.sslv2` module whose - ``SSLv2`` records each delegation, so a test does not depend on the real - dissector. - - """ - import types - - from pcapkit.const.tls.content_type import ContentType - from pcapkit.protocols.data.application.tls import TLS as Data_TLS - - class StandInSSLv2: - alias = 'SSLv2' - name = 'Secure Sockets Layer version 2' - info = Data_TLS(content_type=ContentType.handshake, version=0x0002, - length=3, content=b'\x01\x00\x00') - _next = None - calls = [] # type: list[bytes] - - def __init__(self, data: bytes, length: int, **kwargs: object) -> None: - self.calls.append(data[:length]) - - module = types.ModuleType(_SSLV2_MODULE) - module.SSLv2 = StandInSSLv2 # type: ignore[attr-defined] - return module - - 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 @@ -260,9 +228,9 @@ def test_content_type_zero_is_rejected(self) -> None: 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, and - 32 and 63 the ends of the 32-63 band :rfc:`9147` reserves; 64-255 is - SSLv2-shaped (:class:`SSLv2HeuristicTests`). 27, :rfc:`9853`'s + 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 @@ -273,7 +241,7 @@ def test_content_types_outside_the_assigned_range_are_rejected(self) -> None: from pcapkit.protocols.application.tls import TLS from pcapkit.utilities.exceptions import ProtocolError - for content_type in (19, 28, 32, 63): + 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): @@ -334,18 +302,12 @@ def test_a_legacy_record_version_outside_the_protocol_range_is_rejected(self) -> value neither uses, so neither half can pass by accepting everything. """ - import sys - from unittest import mock - from pcapkit.protocols.application.dtls import DTLS from pcapkit.protocols.application.tls import TLS from pcapkit.utilities.exceptions import ProtocolError - # NOTE: a second octet other than 0x03 is SSLv2-shaped, so SSLv2 is - # blocked to reach the TLS validator. for version in (b'\x02\x03', b'\xfe\xfd'): - with self.subTest(protocol='TLS', version=version.hex()), \ - mock.patch.dict(sys.modules, {_SSLV2_MODULE: None}): + with self.subTest(protocol='TLS', version=version.hex()): wire = b'\x17' + version + b'\x00\x05hello' with self.assertRaises(ProtocolError): TLS(wire, len(wire)) @@ -402,9 +364,6 @@ def test_a_rejected_record_degrades_to_raw_rather_than_propagating(self) -> None to ``Raw`` unconditionally. """ - import sys - from unittest import mock - from pcapkit.protocols.transport.tcp import TCP from pcapkit.protocols.transport.udp import UDP @@ -418,13 +377,10 @@ def test_a_rejected_record_degrades_to_raw_rather_than_propagating(self) -> None ('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'), ) - # NOTE: SSLv2 is blocked, since the DTLS legacy version case is - # SSLv2-shaped. for name, payload, expected in tls_cases: with self.subTest(transport='TCP:443', payload=name): raw = _TCP_TO_443 + payload - with mock.patch.dict(sys.modules, {_SSLV2_MODULE: None}), \ - warnings.catch_warnings(): + with warnings.catch_warnings(): warnings.simplefilter('ignore') proto = TCP(raw, len(raw)) self.assertEqual(str(proto.protochain), expected) @@ -928,137 +884,49 @@ def test_malformed_client_hello_raises_through_dispatch_same_as_before(self) -> TLS(wire, len(wire)) -class SSLv2HeuristicTests(unittest.TestCase): - """:func:`pcapkit.protocols.application.tls._looks_like_sslv2` -- the - disambiguation :meth:`TLS.read - ` uses to route - port-443 traffic to :class:`~pcapkit.protocols.application.sslv2.SSLv2` - rather than the TLS family. - - """ +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_two_octet_sslv2_header_is_recognised(self) -> None: - from pcapkit.protocols.application.tls import _looks_like_sslv2 - - self.assertTrue(_looks_like_sslv2(b'\x81\x00\x01')) - - def test_three_octet_escaped_sslv2_header_is_recognised(self) -> None: - from pcapkit.protocols.application.tls import _looks_like_sslv2 - - self.assertTrue(_looks_like_sslv2(b'\x41\x00\x01')) - - def test_three_octet_plain_sslv2_header_is_recognised(self) -> None: - """The genuinely ambiguous case: top bits ``00``, like a real content type.""" - from pcapkit.protocols.application.tls import _looks_like_sslv2 - - # Second octet is not 0x03, so this cannot be a TLS record either. - self.assertTrue(_looks_like_sslv2(b'\x01\x00\x01')) - - def test_a_genuine_tls_record_is_not_misread_as_sslv2(self) -> None: - from pcapkit.protocols.application.tls import _looks_like_sslv2 - - for content_type in range(0x40): - with self.subTest(content_type=hex(content_type)): - self.assertFalse(_looks_like_sslv2(bytes([content_type, 0x03, 0x03]))) - - def test_a_first_octet_no_content_type_can_take_reads_as_sslv2(self) -> None: - """64-255 opens an SSLv2 2-octet or escaped header even when a - ``0x03`` follows.""" - from pcapkit.protocols.application.tls import _looks_like_sslv2 - - for first in range(0x40, 0x100): - with self.subTest(first=hex(first)): - self.assertTrue(_looks_like_sslv2(bytes([first, 0x03, 0x03]))) - - def test_too_short_to_tell_reads_as_sslv2(self) -> None: - from pcapkit.protocols.application.tls import _looks_like_sslv2 - - self.assertTrue(_looks_like_sslv2(b'')) - self.assertTrue(_looks_like_sslv2(b'\x16')) # no second octet to check - - def test_sslv2_shaped_payload_degrades_to_raw_when_sslv2_is_unavailable(self) -> None: - """With SSLv2 support (:issue:`2457`) unavailable, dispatch still - identifies the shape and tries to import it; the :exc:`ImportError` - falls through to the TLS-family dispatch, whose own validation then - rejects the payload -- degrading to - :class:`~pcapkit.protocols.misc.raw.Raw` rather than crashing. + 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 - """ - import sys - from unittest import mock + 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 - sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header - raw = _TCP_TO_443 + sslv2_shaped - with mock.patch.dict(sys.modules, {_SSLV2_MODULE: None}), warnings.catch_warnings(): + 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_delegated_parse_adopts_both_alias_and_name(self) -> None: - """:meth:`SSL._adopt_dispatched` carries the delegate's :attr:`name` - over alongside its :attr:`alias`, as :meth:`TLS.read` sets both when - it parses the record itself. - - """ - import sys - from unittest import mock - - from pcapkit.protocols.application.tls import TLS - - module = _stand_in_sslv2() - sslv2_shaped = b'\x81\x00\x01\x00\x00' # 2-octet SSLv2 header - with mock.patch.dict(sys.modules, {_SSLV2_MODULE: module}): - parsed = TLS(sslv2_shaped, len(sslv2_shaped)) - self.assertEqual(parsed.alias, 'SSLv2') - self.assertEqual(parsed.name, 'Secure Sockets Layer version 2') - self.assertIs(parsed.info, module.SSLv2.info) # type: ignore[attr-defined] - - def test_sslv2_client_hello_on_port_443_dispatches_to_sslv2(self) -> None: - """An SSLv2 ``CLIENT-HELLO`` (2-octet header, ``MSG-TYPE`` 1) on TCP - port 443 reaches the SSLv2 dissector.""" - import sys - from unittest import mock + def test_random_segments_are_raw(self) -> None: + import random from pcapkit.protocols.transport.tcp import TCP - module = _stand_in_sslv2() - body = b'\x01\x00\x02\x00\x03\x00\x00\x00\x10' + b'\x01\x00\x80' + bytes(16) - hello = (0x8000 | len(body)).to_bytes(2, 'big') + body - raw = _TCP_TO_443 + hello - with mock.patch.dict(sys.modules, {_SSLV2_MODULE: module}): - proto = TCP(raw, len(raw)) - self.assertEqual(str(proto.protochain), 'TCP:SSLv2') - self.assertEqual(module.SSLv2.calls, [hello]) # type: ignore[attr-defined] - - def test_every_tls_record_shape_stays_tls(self) -> None: - """A record of each content type - :data:`~pcapkit.protocols.application.ssl._WIRE_CONTENT_TYPES` accepts - parses as TLS, and every other first octet below 64 is rejected as - TLS, never delegated to SSLv2.""" - import sys - from unittest import mock - - from pcapkit.protocols.application.ssl import _WIRE_CONTENT_TYPES - from pcapkit.protocols.application.tls import TLS - from pcapkit.utilities.exceptions import ProtocolError - - module = _stand_in_sslv2() - with mock.patch.dict(sys.modules, {_SSLV2_MODULE: module}): - for content_type in range(0x40): - 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)) - self.assertEqual(module.SSLv2.calls, []) # type: ignore[attr-defined] + 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): diff --git a/tests/protocols/test_dispatch_registry_unit.py b/tests/protocols/test_dispatch_registry_unit.py index d190d8a4ec..e39e279b04 100644 --- a/tests/protocols/test_dispatch_registry_unit.py +++ b/tests/protocols/test_dispatch_registry_unit.py @@ -237,8 +237,6 @@ 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, { - # tcp/443 (TLS) and udp/5684 (DTLS) added alongside this PR's - # dissector. 'link': 7, 'internet': 17, 'tcp': 5, 'udp': 4, 'sctp': 2, 'pcap-frame': 5, 'pcapng-frame': 5, 'loopback': 4, }) From 27a7872fd4ef25ce68b6c0b3d91fa1413cca1915 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 19:46:30 -0400 Subject: [PATCH 11/11] docs(ssl): drop the stale SSLv2 forward reference (#2441) Remove "The SSLv2 class joins it in its own PR." from SSL.id() and ssl.rst, and restore the E302 blank line before _udp_to_5684. --- docs/source/pcapkit/protocols/application/ssl.rst | 3 +-- pcapkit/protocols/application/ssl.py | 2 +- tests/protocols/application/test_ssl_tls_dtls_unit.py | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/pcapkit/protocols/application/ssl.rst b/docs/source/pcapkit/protocols/application/ssl.rst index f216a58580..c44e49b423 100644 --- a/docs/source/pcapkit/protocols/application/ssl.rst +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -14,8 +14,7 @@ family [*]_ -- the SSL versions, Transport Layer Security and Datagram TLS: * :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 -SSLv2 class joins it in its own PR. +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 diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index 516f7f449d..bf82524d3e 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -192,7 +192,7 @@ class SSL(Application[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract @classmethod def id(cls) -> 'tuple[Literal["TLS"], Literal["DTLS"]]': - """Index ID of the protocol. The SSLv2 class joins it in its own PR. + """Index ID of the protocol. Returns: Index ID of the protocol. diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index 3d6bad1592..3614adfbe7 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -17,6 +17,7 @@ #: :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