diff --git a/Pipfile b/Pipfile index 4297ebebf0..0efd5b3abd 100644 --- a/Pipfile +++ b/Pipfile @@ -15,6 +15,7 @@ pypcapkit = {editable = true,path = "."} pyshark = "*" dpkt = "*" scapy = "*" +cryptography = "*" beautifulsoup4 = {extras = ["html5lib"],version = "*"} requests = {extras = ["socks"],version = "*"} autopep8 = "*" diff --git a/docs/source/pcapkit/const/esp.rst b/docs/source/pcapkit/const/esp.rst new file mode 100644 index 0000000000..7e8ad84675 --- /dev/null +++ b/docs/source/pcapkit/const/esp.rst @@ -0,0 +1,59 @@ +================================================================== +:class:`~pcapkit.protocols.internet.esp.ESP` Constant Enumerations +================================================================== + +.. module:: pcapkit.const.esp + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.internet.esp.ESP` implementations. Available +enumerations include: + +.. list-table:: + + * - :class:`ESP_Cipher ` + - Encryption Algorithm Transform IDs [*]_ + * - :class:`ESP_Integrity ` + - Integrity Algorithm Transform IDs [*]_ + +ESP has no algorithm registry of its own: an SA's algorithms are negotiated by +IKEv2, so both enumerations are the corresponding IKEv2 *transform ID* +sub-registries. They live here rather than under an ``ikev2`` package because +:class:`~pcapkit.protocols.internet.esp.ESP` is the only thing in +:mod:`pcapkit` that consumes them. + +Both enumerate every transform **IANA has registered**, which is a much larger +set than :mod:`pcapkit` can apply. Which of them ESP actually implements is a +separate question, answered by +:data:`~pcapkit.protocols.internet.esp.CIPHER_SUITES` and +:data:`~pcapkit.protocols.internet.esp.INTEGRITY_SUITES`. + +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-5 +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-7 + +ESP Encryption Algorithm Transform IDs +====================================== + +.. module:: pcapkit.const.esp.cipher + +This module contains the constant enumeration for **Transform Type 1 - +Encryption Algorithm Transform IDs**, which is automatically generated from +:class:`pcapkit.vendor.esp.cipher.Cipher`. + +.. autoclass:: pcapkit.const.esp.cipher.Cipher + :members: + :undoc-members: + :show-inheritance: + +ESP Integrity Algorithm Transform IDs +===================================== + +.. module:: pcapkit.const.esp.integrity + +This module contains the constant enumeration for **Transform Type 3 - +Integrity Algorithm Transform IDs**, which is automatically generated from +:class:`pcapkit.vendor.esp.integrity.Integrity`. + +.. autoclass:: pcapkit.const.esp.integrity.Integrity + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pcapkit/const/index.rst b/docs/source/pcapkit/const/index.rst index 02149563c8..6c435951b0 100644 --- a/docs/source/pcapkit/const/index.rst +++ b/docs/source/pcapkit/const/index.rst @@ -39,6 +39,7 @@ Internet Layer .. toctree:: :maxdepth: 2 + esp hip ipv4 ipv6 diff --git a/docs/source/pcapkit/corekit/context.rst b/docs/source/pcapkit/corekit/context.rst new file mode 100644 index 0000000000..62a17bab09 --- /dev/null +++ b/docs/source/pcapkit/corekit/context.rst @@ -0,0 +1,29 @@ +Parsing Context +=============== + +.. module:: pcapkit.corekit.context + +.. automodule:: pcapkit.corekit.context + :no-members: + +.. autoclass:: pcapkit.corekit.context.ProtocolContext + :no-members: + :show-inheritance: + + .. automethod:: protocol + .. automethod:: __repr__ + +.. autoclass:: pcapkit.corekit.context.ContextRegistry + :no-members: + :show-inheritance: + + .. automethod:: register + .. automethod:: make + .. automethod:: match + + .. automethod:: __getitem__ + .. automethod:: __iter__ + .. automethod:: __len__ + .. automethod:: __contains__ + .. automethod:: __bool__ + .. automethod:: __repr__ diff --git a/docs/source/pcapkit/corekit/index.rst b/docs/source/pcapkit/corekit/index.rst index 9c172de244..f19ffe30a4 100644 --- a/docs/source/pcapkit/corekit/index.rst +++ b/docs/source/pcapkit/corekit/index.rst @@ -10,12 +10,15 @@ class :class:`~pcapkit.corekit.infoclass.Info`, protocol collection class :class:`~pcapkit.corekit.protochain.ProtoChain`, and :class:`~pcapkit.corekit.multidict.MultiDict` family inspired from :mod:`Werkzeug` for multientry :obj:`dict` data mapping, the -:class:`~pcapkit.corekit.fields.field.Field` family for data parsing. +:class:`~pcapkit.corekit.fields.field.Field` family for data parsing, and +the :class:`~pcapkit.corekit.context.ContextRegistry` channel for caller +supplied information that a protocol needs but the wire does not carry. .. toctree:: :maxdepth: 2 fields/index + context infoclass io module diff --git a/docs/source/pcapkit/protocols/internet/esp.rst b/docs/source/pcapkit/protocols/internet/esp.rst new file mode 100644 index 0000000000..faaac74bb9 --- /dev/null +++ b/docs/source/pcapkit/protocols/internet/esp.rst @@ -0,0 +1,123 @@ +ESP - Encapsulating Security Payload +==================================== + +.. module:: pcapkit.protocols.internet.esp + +.. automodule:: pcapkit.protocols.internet.esp + :no-members: + +.. autoclass:: pcapkit.protocols.internet.esp.ESP + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: length + + .. automethod:: id + + .. automethod:: read + .. automethod:: make + + .. automethod:: _make_data + .. automethod:: _payload_bytes + .. automethod:: _read_trailer + .. automethod:: _make_opaque + + .. automethod:: __post_init__ + .. automethod:: __index__ + +Security Associations +--------------------- + +SA context is supplied through the generic, protocol keyed channel of +:mod:`pcapkit.corekit.context`. + +.. autoclass:: pcapkit.protocols.internet.esp.SecurityAssociation + :no-members: + :show-inheritance: + + .. autoproperty:: encryption_key + .. autoproperty:: salt + .. autoproperty:: integrity_key + .. autoproperty:: icv_length + .. autoproperty:: authenticated + + .. automethod:: matches + .. automethod:: unavailable + .. automethod:: compute_icv + .. automethod:: decrypt + .. automethod:: encrypt + + .. automethod:: _split_key + .. automethod:: __repr__ + +.. autoclass:: pcapkit.protocols.internet.esp.ESPContext + :no-members: + :show-inheritance: + + .. autoproperty:: associations + + .. automethod:: protocol + .. automethod:: register + .. automethod:: match + .. automethod:: __repr__ + +Algorithm Registries +-------------------- + +The algorithm enumerations are the IANA IKEv2 transform ID registries, +generated into :mod:`pcapkit.const.esp` and re-exported here for convenience: +:class:`Cipher ` is +:class:`pcapkit.const.esp.cipher.Cipher` and :class:`Integrity +` is +:class:`pcapkit.const.esp.integrity.Integrity`. + +Algorithm Support +----------------- + +A registry enumerates what IANA assigned an ID to, which is far more than +:mod:`pcapkit` implements. The tables below are the authority on what an SA may +actually name, and the two ``get`` methods refuse anything outside them. + +.. autoclass:: pcapkit.protocols.internet.esp.CipherSuite + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.internet.esp.IntegritySuite + :members: + :undoc-members: + :show-inheritance: + +.. autodata:: pcapkit.protocols.internet.esp.CIPHER_SUITES + +.. autodata:: pcapkit.protocols.internet.esp.INTEGRITY_SUITES + +.. autofunction:: pcapkit.protocols.internet.esp._resolve + +Processing Status +----------------- + +.. autoclass:: pcapkit.protocols.internet.esp.ESPStatus + :members: + :show-inheritance: + +.. autofunction:: pcapkit.protocols.internet.esp.load_cryptography + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.internet.esp + +.. autoclass:: pcapkit.protocols.schema.internet.esp.ESP + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.internet.esp + +.. autoclass:: pcapkit.protocols.data.internet.esp.ESP + :members: + :show-inheritance: diff --git a/docs/source/pcapkit/protocols/internet/index.rst b/docs/source/pcapkit/protocols/internet/index.rst index af6394bbb4..3314c15674 100644 --- a/docs/source/pcapkit/protocols/internet/index.rst +++ b/docs/source/pcapkit/protocols/internet/index.rst @@ -21,13 +21,14 @@ internet layer, with detailed implementation and methods. hopopt ipsec ah + esp hip mh ipx .. todo:: - Implements ECN, ESP, ICMP, ICMPv6, IGMP, Shim6. + Implements ECN, ICMP, ICMPv6, IGMP, Shim6. Protocol Registry ----------------- diff --git a/docs/source/pcapkit/protocols/internet/ipsec.rst b/docs/source/pcapkit/protocols/internet/ipsec.rst index 6f96e02a5e..8180ae8f15 100644 --- a/docs/source/pcapkit/protocols/internet/ipsec.rst +++ b/docs/source/pcapkit/protocols/internet/ipsec.rst @@ -8,7 +8,7 @@ IPsec - Internet Protocol Security only, which is a base class for Internet Protocol Security (IPsec) protocol family [*]_, eg. :class:`~pcapkit.protocols.internet.ah.AH` and -:class:`~pcapkit.protocols.internet.esp.ESP` [*]_. +:class:`~pcapkit.protocols.internet.esp.ESP`. .. autoclass:: pcapkit.protocols.internet.ipsec.IPsec :no-members: @@ -22,4 +22,3 @@ Security (IPsec) protocol family [*]_, eg. .. rubric:: Footnotes .. [*] https://en.wikipedia.org/wiki/IPsec -.. [*] :class:`~pcapkit.protocols.internet.esp.ESP` class is currently **NOT** implemented. diff --git a/docs/source/pcapkit/vendor/esp.rst b/docs/source/pcapkit/vendor/esp.rst new file mode 100644 index 0000000000..f144c59ce5 --- /dev/null +++ b/docs/source/pcapkit/vendor/esp.rst @@ -0,0 +1,50 @@ +============================================================ +:class:`~pcapkit.protocols.internet.esp.ESP` Vendor Crawlers +============================================================ + +.. module:: pcapkit.vendor.esp + +This module contains all vendor crawlers of +:class:`~pcapkit.protocols.internet.esp.ESP` implementations. Available +vendor crawlers include: + +.. list-table:: + + * - :class:`ESP_Cipher ` + - Encryption Algorithm Transform IDs [*]_ + * - :class:`ESP_Integrity ` + - Integrity Algorithm Transform IDs [*]_ + +ESP has no algorithm registry of its own: an SA's algorithms are negotiated by +IKEv2, so both crawlers pull the corresponding IKEv2 *transform ID* +sub-registries, which are published as separate CSV files from the IKEv2 +parameters page. + +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-5 +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-7 + +ESP Encryption Algorithm Transform IDs +====================================== + +.. module:: pcapkit.vendor.esp.cipher + +This module contains the vendor crawler for **Transform Type 1 - Encryption +Algorithm Transform IDs**, which is automatically generating +:class:`pcapkit.const.esp.cipher.Cipher`. + +.. autoclass:: pcapkit.vendor.esp.cipher.Cipher + :members: FLAG, LINK + :show-inheritance: + +ESP Integrity Algorithm Transform IDs +===================================== + +.. module:: pcapkit.vendor.esp.integrity + +This module contains the vendor crawler for **Transform Type 3 - Integrity +Algorithm Transform IDs**, which is automatically generating +:class:`pcapkit.const.esp.integrity.Integrity`. + +.. autoclass:: pcapkit.vendor.esp.integrity.Integrity + :members: FLAG, LINK + :show-inheritance: diff --git a/docs/source/pcapkit/vendor/index.rst b/docs/source/pcapkit/vendor/index.rst index 56c96e3de4..0b2f4eaeb1 100644 --- a/docs/source/pcapkit/vendor/index.rst +++ b/docs/source/pcapkit/vendor/index.rst @@ -49,6 +49,7 @@ Internet Layer .. toctree:: :maxdepth: 2 + esp hip ipv4 ipv6 diff --git a/docs/source/pep.rst b/docs/source/pep.rst index a0fa1f4808..576082ae45 100644 --- a/docs/source/pep.rst +++ b/docs/source/pep.rst @@ -42,15 +42,19 @@ but not yet done. Namely, grouped by each TCP/IP layer and ordered by protocol name alphabetically, * Link Layer: DSL, EAPOL, FDDI, ISDN, PPP -* Internet Layer: ECN, ESP, ICMP, ICMPv6, IGMP, NDP, Shim6 +* Internet Layer: ECN, ICMP, ICMPv6, IGMP, NDP, Shim6 * Transport Layer: DCCP, QUIC, RSVP * Application Layer: BGP, DHCP, DHCPv6, DNS, IMAP, LDAP, MQTT, NNTP, NTP, ONC/RPC, POP, RIP, RTP, SIP, SMTP, SNMP, SSH, Telnet, TLS/SSL, XMPP -Specifically, I have attempted to implement **ESP** several years ago, and I -abandoned the implementation in the `NotImplemented` folder due to some design -flaws within PyPCAPKit at that time. But now, the protocol should be able to -implement quite smoothly. +**ESP** -- abandoned in the ``NotImplemented`` folder for years, because of +design flaws within PyPCAPKit at the time -- is now implemented, c.f. +:class:`~pcapkit.protocols.internet.esp.ESP`. It parses without keys, and +decrypts when a Security Association is supplied through the protocol keyed +:mod:`pcapkit.corekit.context` channel. What is still wanted there is wider +algorithm coverage: ChaCha20-Poly1305 [:rfc:`7634`], AES-CCM [:rfc:`4309`] and +AES-XCBC integrity [:rfc:`3566`] are not implemented, and neither are Extended +Sequence Numbers. More over, :class:`~pcapkit.protocols.internet.mh.MH` requires some help to implement all the *message data* types, you can find more information in the diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index 4ce1b21711..2e95848b7d 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -110,7 +110,7 @@ 'ARP', 'Ethernet', 'L2TP', 'OSPF', 'RARP', 'VLAN', # Link Layer - 'AH', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer + 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header diff --git a/pcapkit/const/__init__.py b/pcapkit/const/__init__.py index 4f9dd953d4..6d6ae59419 100644 --- a/pcapkit/const/__init__.py +++ b/pcapkit/const/__init__.py @@ -16,6 +16,7 @@ # per protocol from pcapkit.const.arp import * +from pcapkit.const.esp import * from pcapkit.const.ftp import * from pcapkit.const.hip import * from pcapkit.const.http import * @@ -34,6 +35,8 @@ 'ETHERTYPE', 'LINKTYPE', 'TRANSTYPE', 'APPTYPE', # ARP 'ARP_Hardware', 'ARP_Operation', + # ESP + 'ESP_Cipher', 'ESP_Integrity', # FTP 'FTP_Command', 'FTP_ReturnCode', # HIP diff --git a/pcapkit/const/esp/__init__.py b/pcapkit/const/esp/__init__.py new file mode 100644 index 0000000000..5b196001f4 --- /dev/null +++ b/pcapkit/const/esp/__init__.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +""":class:`~pcapkit.protocols.internet.esp.ESP` Constant Enumerations +======================================================================= + +.. module:: pcapkit.const.esp + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.internet.esp.ESP` implementations. Available +enumerations include: + +.. list-table:: + + * - :class:`ESP_Cipher ` + - Encryption Algorithm Transform IDs [*]_ + * - :class:`ESP_Integrity ` + - Integrity Algorithm Transform IDs [*]_ + +ESP has no algorithm registry of its own: an SA's algorithms are negotiated by +IKEv2, so both enumerations are the corresponding IKEv2 *transform ID* +sub-registries. They live here rather than under an ``ikev2`` package because +:class:`~pcapkit.protocols.internet.esp.ESP` is the only thing in +:mod:`pcapkit` that consumes them. + +Both enumerate every transform **IANA has registered**, which is a much larger +set than :mod:`pcapkit` can apply. Which of them ESP actually implements is a +separate question, answered by +:data:`~pcapkit.protocols.internet.esp.CIPHER_SUITES` and +:data:`~pcapkit.protocols.internet.esp.INTEGRITY_SUITES`. + +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-5 +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-7 + +""" + +from pcapkit.const.esp.cipher import Cipher as ESP_Cipher +from pcapkit.const.esp.integrity import Integrity as ESP_Integrity + +__all__ = ['ESP_Cipher', 'ESP_Integrity'] diff --git a/pcapkit/const/esp/cipher.py b/pcapkit/const/esp/cipher.py new file mode 100644 index 0000000000..779b561e90 --- /dev/null +++ b/pcapkit/const/esp/cipher.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""Transform Type 1 - Encryption Algorithm Transform IDs +=========================================================== + +.. module:: pcapkit.const.esp.cipher + +This module contains the constant enumeration for **Transform Type 1 - Encryption Algorithm Transform IDs**, +which is automatically generated from :class:`pcapkit.vendor.esp.cipher.Cipher`. + +""" + +from aenum import IntEnum, extend_enum + +__all__ = ['Cipher'] + + +class Cipher(IntEnum): + """[Cipher] Transform Type 1 - Encryption Algorithm Transform IDs""" + + #: Reserved [:rfc:`7296`] + Reserved_0 = 0 + + #: ENCR_DES_IV64 (DEPRECATED [:rfc:`9395`]) + ENCR_DES_IV64 = 1 + + #: Alias of :attr:`Cipher.ENCR_DES_IV64`. + DES_IV64 = 1 + + #: ENCR_DES [:rfc:`2405`] (DEPRECATED [:rfc:`8247`]) + ENCR_DES = 2 + + #: Alias of :attr:`Cipher.ENCR_DES`. + DES = 2 + + #: ENCR_3DES [:rfc:`2451`] + ENCR_3DES = 3 + + #: ENCR_RC5 [:rfc:`2451`] (DEPRECATED [:rfc:`9395`]) + ENCR_RC5 = 4 + + #: Alias of :attr:`Cipher.ENCR_RC5`. + RC5 = 4 + + #: ENCR_IDEA [:rfc:`2451`] (DEPRECATED [:rfc:`9395`]) + ENCR_IDEA = 5 + + #: Alias of :attr:`Cipher.ENCR_IDEA`. + IDEA = 5 + + #: ENCR_CAST [:rfc:`2451`] (DEPRECATED [:rfc:`9395`]) + ENCR_CAST = 6 + + #: Alias of :attr:`Cipher.ENCR_CAST`. + CAST = 6 + + #: ENCR_BLOWFISH [:rfc:`2451`] (DEPRECATED [:rfc:`9395`]) + ENCR_BLOWFISH = 7 + + #: Alias of :attr:`Cipher.ENCR_BLOWFISH`. + BLOWFISH = 7 + + #: ENCR_3IDEA (DEPRECATED [:rfc:`9395`]) + ENCR_3IDEA = 8 + + #: ENCR_DES_IV32 (DEPRECATED [:rfc:`9395`]) + ENCR_DES_IV32 = 9 + + #: Alias of :attr:`Cipher.ENCR_DES_IV32`. + DES_IV32 = 9 + + #: Reserved [:rfc:`7296`] + Reserved_10 = 10 + + #: ENCR_NULL [:rfc:`2410`] + ENCR_NULL = 11 + + #: Alias of :attr:`Cipher.ENCR_NULL`. + NULL = 11 + + #: ENCR_AES_CBC [:rfc:`3602`] + ENCR_AES_CBC = 12 + + #: Alias of :attr:`Cipher.ENCR_AES_CBC`. + AES_CBC = 12 + + #: ENCR_AES_CTR [:rfc:`3686`] + ENCR_AES_CTR = 13 + + #: Alias of :attr:`Cipher.ENCR_AES_CTR`. + AES_CTR = 13 + + #: ENCR_AES_CCM_8 [:rfc:`4309`] + ENCR_AES_CCM_8 = 14 + + #: Alias of :attr:`Cipher.ENCR_AES_CCM_8`. + AES_CCM_8 = 14 + + #: ENCR_AES_CCM_12 [:rfc:`4309`] + ENCR_AES_CCM_12 = 15 + + #: Alias of :attr:`Cipher.ENCR_AES_CCM_12`. + AES_CCM_12 = 15 + + #: ENCR_AES_CCM_16 [:rfc:`4309`] + ENCR_AES_CCM_16 = 16 + + #: Alias of :attr:`Cipher.ENCR_AES_CCM_16`. + AES_CCM_16 = 16 + + #: Unassigned + Unassigned_17 = 17 + + #: ENCR_AES_GCM_8 [:rfc:`4106`][:rfc:`8247`] + ENCR_AES_GCM_8 = 18 + + #: Alias of :attr:`Cipher.ENCR_AES_GCM_8`. + AES_GCM_8 = 18 + + #: ENCR_AES_GCM_12 [:rfc:`4106`][:rfc:`8247`] + ENCR_AES_GCM_12 = 19 + + #: Alias of :attr:`Cipher.ENCR_AES_GCM_12`. + AES_GCM_12 = 19 + + #: ENCR_AES_GCM_16 [:rfc:`4106`][:rfc:`8247`] + ENCR_AES_GCM_16 = 20 + + #: Alias of :attr:`Cipher.ENCR_AES_GCM_16`. + AES_GCM_16 = 20 + + #: ENCR_NULL_AUTH_AES_GMAC [:rfc:`4543`] + ENCR_NULL_AUTH_AES_GMAC = 21 + + #: Alias of :attr:`Cipher.ENCR_NULL_AUTH_AES_GMAC`. + NULL_AUTH_AES_GMAC = 21 + + #: Reserved for IEEE P1619 XTS-AES [Matt Ball] + Reserved_for_IEEE_P1619_XTS_AES = 22 + + #: ENCR_CAMELLIA_CBC [:rfc:`5529`] + ENCR_CAMELLIA_CBC = 23 + + #: Alias of :attr:`Cipher.ENCR_CAMELLIA_CBC`. + CAMELLIA_CBC = 23 + + #: ENCR_CAMELLIA_CTR [:rfc:`5529`] + ENCR_CAMELLIA_CTR = 24 + + #: Alias of :attr:`Cipher.ENCR_CAMELLIA_CTR`. + CAMELLIA_CTR = 24 + + #: ENCR_CAMELLIA_CCM_8 [:rfc:`5529`][:rfc:`8247`] + ENCR_CAMELLIA_CCM_8 = 25 + + #: Alias of :attr:`Cipher.ENCR_CAMELLIA_CCM_8`. + CAMELLIA_CCM_8 = 25 + + #: ENCR_CAMELLIA_CCM_12 [:rfc:`5529`][:rfc:`8247`] + ENCR_CAMELLIA_CCM_12 = 26 + + #: Alias of :attr:`Cipher.ENCR_CAMELLIA_CCM_12`. + CAMELLIA_CCM_12 = 26 + + #: ENCR_CAMELLIA_CCM_16 [:rfc:`5529`][:rfc:`8247`] + ENCR_CAMELLIA_CCM_16 = 27 + + #: Alias of :attr:`Cipher.ENCR_CAMELLIA_CCM_16`. + CAMELLIA_CCM_16 = 27 + + #: ENCR_CHACHA20_POLY1305 [:rfc:`7634`] + ENCR_CHACHA20_POLY1305 = 28 + + #: Alias of :attr:`Cipher.ENCR_CHACHA20_POLY1305`. + CHACHA20_POLY1305 = 28 + + #: ENCR_AES_CCM_8_IIV [:rfc:`8750`] + ENCR_AES_CCM_8_IIV = 29 + + #: Alias of :attr:`Cipher.ENCR_AES_CCM_8_IIV`. + AES_CCM_8_IIV = 29 + + #: ENCR_AES_GCM_16_IIV [:rfc:`8750`] + ENCR_AES_GCM_16_IIV = 30 + + #: Alias of :attr:`Cipher.ENCR_AES_GCM_16_IIV`. + AES_GCM_16_IIV = 30 + + #: ENCR_CHACHA20_POLY1305_IIV [:rfc:`8750`] + ENCR_CHACHA20_POLY1305_IIV = 31 + + #: Alias of :attr:`Cipher.ENCR_CHACHA20_POLY1305_IIV`. + CHACHA20_POLY1305_IIV = 31 + + #: ENCR_KUZNYECHIK_MGM_KTREE [:rfc:`9227`] + ENCR_KUZNYECHIK_MGM_KTREE = 32 + + #: Alias of :attr:`Cipher.ENCR_KUZNYECHIK_MGM_KTREE`. + KUZNYECHIK_MGM_KTREE = 32 + + #: ENCR_MAGMA_MGM_KTREE [:rfc:`9227`] + ENCR_MAGMA_MGM_KTREE = 33 + + #: Alias of :attr:`Cipher.ENCR_MAGMA_MGM_KTREE`. + MAGMA_MGM_KTREE = 33 + + #: ENCR_KUZNYECHIK_MGM_MAC_KTREE [:rfc:`9227`] + ENCR_KUZNYECHIK_MGM_MAC_KTREE = 34 + + #: Alias of :attr:`Cipher.ENCR_KUZNYECHIK_MGM_MAC_KTREE`. + KUZNYECHIK_MGM_MAC_KTREE = 34 + + #: ENCR_MAGMA_MGM_MAC_KTREE [:rfc:`9227`] + ENCR_MAGMA_MGM_MAC_KTREE = 35 + + #: Alias of :attr:`Cipher.ENCR_MAGMA_MGM_MAC_KTREE`. + MAGMA_MGM_MAC_KTREE = 35 + + @staticmethod + def get(key: 'int | str', default: 'int' = -1) -> 'Cipher': + """Backport support for original codes. + + Args: + key: Key to get enum item. + default: Default value if not found. + + :meta private: + """ + if isinstance(key, int): + return Cipher(key) + if key not in Cipher._member_map_: # pylint: disable=no-member + return extend_enum(Cipher, key, default) + return Cipher[key] # type: ignore[misc] + + @classmethod + def _missing_(cls, value: 'int') -> 'Cipher': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0 <= value <= 65535): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + if 36 <= value <= 1023: + #: Unassigned + return extend_enum(cls, 'Unassigned_%d' % value, value) + if 1024 <= value <= 65535: + #: Reserved for Private Use [:rfc:`7296`] + return extend_enum(cls, 'Reserved_for_Private_Use_%d' % value, value) + return super()._missing_(value) diff --git a/pcapkit/const/esp/integrity.py b/pcapkit/const/esp/integrity.py new file mode 100644 index 0000000000..7d5101d542 --- /dev/null +++ b/pcapkit/const/esp/integrity.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""Transform Type 3 - Integrity Algorithm Transform IDs +========================================================== + +.. module:: pcapkit.const.esp.integrity + +This module contains the constant enumeration for **Transform Type 3 - Integrity Algorithm Transform IDs**, +which is automatically generated from :class:`pcapkit.vendor.esp.integrity.Integrity`. + +""" + +from aenum import IntEnum, extend_enum + +__all__ = ['Integrity'] + + +class Integrity(IntEnum): + """[Integrity] Transform Type 3 - Integrity Algorithm Transform IDs""" + + #: NONE [:rfc:`7296`] + NONE = 0 + + #: AUTH_HMAC_MD5_96 [:rfc:`2403`][:rfc:`7296`] (DEPRECATED [:rfc:`8247`]) + AUTH_HMAC_MD5_96 = 1 + + #: Alias of :attr:`Integrity.AUTH_HMAC_MD5_96`. + HMAC_MD5_96 = 1 + + #: AUTH_HMAC_SHA1_96 [:rfc:`2404`][:rfc:`7296`] + AUTH_HMAC_SHA1_96 = 2 + + #: Alias of :attr:`Integrity.AUTH_HMAC_SHA1_96`. + HMAC_SHA1_96 = 2 + + #: AUTH_DES_MAC [UNSPECIFIED] (DEPRECATED [:rfc:`8247`]) + AUTH_DES_MAC = 3 + + #: Alias of :attr:`Integrity.AUTH_DES_MAC`. + DES_MAC = 3 + + #: AUTH_KPDK_MD5 [UNSPECIFIED] (DEPRECATED [:rfc:`8247`]) + AUTH_KPDK_MD5 = 4 + + #: Alias of :attr:`Integrity.AUTH_KPDK_MD5`. + KPDK_MD5 = 4 + + #: AUTH_AES_XCBC_96 [:rfc:`3566`][:rfc:`7296`] + AUTH_AES_XCBC_96 = 5 + + #: Alias of :attr:`Integrity.AUTH_AES_XCBC_96`. + AES_XCBC_96 = 5 + + #: AUTH_HMAC_MD5_128 [:rfc:`4595`] (DEPRECATED [:rfc:`9395`]) + AUTH_HMAC_MD5_128 = 6 + + #: Alias of :attr:`Integrity.AUTH_HMAC_MD5_128`. + HMAC_MD5_128 = 6 + + #: AUTH_HMAC_SHA1_160 [:rfc:`4595`] (DEPRECATED [:rfc:`9395`]) + AUTH_HMAC_SHA1_160 = 7 + + #: Alias of :attr:`Integrity.AUTH_HMAC_SHA1_160`. + HMAC_SHA1_160 = 7 + + #: AUTH_AES_CMAC_96 [:rfc:`4494`] + AUTH_AES_CMAC_96 = 8 + + #: Alias of :attr:`Integrity.AUTH_AES_CMAC_96`. + AES_CMAC_96 = 8 + + #: AUTH_AES_128_GMAC [:rfc:`4543`] + AUTH_AES_128_GMAC = 9 + + #: Alias of :attr:`Integrity.AUTH_AES_128_GMAC`. + AES_128_GMAC = 9 + + #: AUTH_AES_192_GMAC [:rfc:`4543`] + AUTH_AES_192_GMAC = 10 + + #: Alias of :attr:`Integrity.AUTH_AES_192_GMAC`. + AES_192_GMAC = 10 + + #: AUTH_AES_256_GMAC [:rfc:`4543`] + AUTH_AES_256_GMAC = 11 + + #: Alias of :attr:`Integrity.AUTH_AES_256_GMAC`. + AES_256_GMAC = 11 + + #: AUTH_HMAC_SHA2_256_128 [:rfc:`4868`] + AUTH_HMAC_SHA2_256_128 = 12 + + #: Alias of :attr:`Integrity.AUTH_HMAC_SHA2_256_128`. + HMAC_SHA2_256_128 = 12 + + #: AUTH_HMAC_SHA2_384_192 [:rfc:`4868`] + AUTH_HMAC_SHA2_384_192 = 13 + + #: Alias of :attr:`Integrity.AUTH_HMAC_SHA2_384_192`. + HMAC_SHA2_384_192 = 13 + + #: AUTH_HMAC_SHA2_512_256 [:rfc:`4868`] + AUTH_HMAC_SHA2_512_256 = 14 + + #: Alias of :attr:`Integrity.AUTH_HMAC_SHA2_512_256`. + HMAC_SHA2_512_256 = 14 + + @staticmethod + def get(key: 'int | str', default: 'int' = -1) -> 'Integrity': + """Backport support for original codes. + + Args: + key: Key to get enum item. + default: Default value if not found. + + :meta private: + """ + if isinstance(key, int): + return Integrity(key) + if key not in Integrity._member_map_: # pylint: disable=no-member + return extend_enum(Integrity, key, default) + return Integrity[key] # type: ignore[misc] + + @classmethod + def _missing_(cls, value: 'int') -> 'Integrity': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0 <= value <= 65535): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + if 15 <= value <= 1023: + #: Unassigned + return extend_enum(cls, 'Unassigned_%d' % value, value) + if 1024 <= value <= 65535: + #: Reserved for Private Use [:rfc:`7296`] + return extend_enum(cls, 'Reserved_for_Private_Use_%d' % value, value) + return super()._missing_(value) diff --git a/pcapkit/corekit/context.py b/pcapkit/corekit/context.py new file mode 100644 index 0000000000..8866cb93db --- /dev/null +++ b/pcapkit/corekit/context.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +"""Parsing Context +===================== + +.. module:: pcapkit.corekit.context + +:mod:`pcapkit.corekit.context` provides a *protocol keyed* channel for +caller supplied information that a protocol needs in order to parse a +packet, but that is **not** carried on the wire. + +Most protocols are self describing -- every length, offset and type that +:mod:`pcapkit` needs to walk a packet is present in the packet itself. A +few are not. :class:`~pcapkit.protocols.internet.esp.ESP` is the +motivating example: :rfc:`4303` deliberately leaves the payload length, +the position of the ``Pad Length`` / ``Next Header`` trailer and the +length of the ``Integrity Check Value`` to be derived from the Security +Association (SA), which is negotiated out of band and is therefore +knowable only to the caller. + +Rather than adding protocol specific keyword arguments to +:class:`~pcapkit.foundation.extraction.Extractor`, such information is +passed as a :class:`ContextRegistry` -- a mapping of protocol index ID +(c.f. :meth:`Protocol.id `) +to a :class:`ProtocolContext` instance. The registry is handed to +:class:`~pcapkit.foundation.extraction.Extractor` once, and is then +propagated down the protocol stack by +:meth:`Protocol._import_next_layer `, +so that a protocol nested arbitrarily deep can reach it through +:meth:`Protocol._get_context `. + +Example: + Decoding an ESP tunnel end to end:: + + >>> import pcapkit + >>> from pcapkit.protocols.internet.esp import (Cipher, ESPContext, + ... Integrity, SecurityAssociation) + >>> sa = SecurityAssociation( + ... spi=0x4321, + ... encryption=Cipher.AES_CBC, + ... encryption_key=bytes.fromhex('90d382b410eeba7ad938c46cec1a82bf'), + ... ) + >>> extraction = pcapkit.extract('esp.pcap', context=ESPContext(sa)) + +Important: + A context object frequently holds secrets -- ESP encryption and + integrity keys, for instance. Contexts are therefore held as plain + instance attributes on the protocol object and are **never** written + into the protocol's data model, which is the only thing that reaches + :meth:`Info.to_dict ` and, + from there, the output dumpers. Implementations of + :class:`ProtocolContext` are expected to keep secrets out of their + :meth:`~object.__repr__` as well. + +""" +import abc +import collections.abc +from typing import TYPE_CHECKING, TypeVar + +from pcapkit.utilities.compat import Mapping +from pcapkit.utilities.exceptions import RegistryError + +__all__ = ['ProtocolContext', 'ContextRegistry'] + +if TYPE_CHECKING: + from typing import Any, Iterable, Iterator, Optional, Type + + from typing_extensions import Self + +_CT = TypeVar('_CT', bound='ProtocolContext') + + +class ProtocolContext(metaclass=abc.ABCMeta): + """Abstract base class for caller supplied protocol parsing context. + + A subclass carries whatever out-of-band information the corresponding + protocol needs, and declares which protocol it applies to through + :meth:`protocol`. + + Warning: + Should the context hold secrets, the subclass **must** override + :meth:`~object.__repr__` so that they are not printed. The default + implementation below prints the class name and the protocol names + only, and is safe in that respect. + + """ + + @classmethod + @abc.abstractmethod + def protocol(cls) -> 'tuple[str, ...]': + """Index ID of the protocol(s) this context applies to. + + The returned names are matched against + :meth:`Protocol.id `, + and are case insensitive. + + """ + + def __repr__(self) -> 'str': + """Representation of the context, free of any secrets.""" + return f'<{type(self).__name__} protocol={"|".join(self.protocol())}>' + + +class ContextRegistry(Mapping[str, 'ProtocolContext']): + """Protocol keyed collection of :class:`ProtocolContext` instances. + + Args: + *contexts: Context instances, each keyed by its own + :meth:`ProtocolContext.protocol`. + **named: Context instances keyed explicitly by protocol index ID. + + """ + + def __init__(self, *contexts: 'ProtocolContext', **named: 'ProtocolContext') -> 'None': + self.__data__ = {} # type: dict[str, ProtocolContext] + + for context in contexts: + self.register(context) + for name, context in named.items(): + self.register(context, name=name) + + ########################################################################## + # Methods. + ########################################################################## + + def register(self, context: 'ProtocolContext', *, name: 'Optional[str]' = None) -> 'None': + """Register ``context`` under one or more protocol index IDs. + + Args: + context: Context instance to register. + name: Protocol index ID to register the context under; if not + given, :meth:`ProtocolContext.protocol` is used, which is + the usual case. + + Raises: + RegistryError: If ``context`` is not a :class:`ProtocolContext`, + or if a context is already registered for the same protocol. + + """ + if not isinstance(context, ProtocolContext): + raise RegistryError(f'not a protocol context: {context!r}') + + names = (name,) if name is not None else context.protocol() + for key in names: + index = key.upper() + if index in self.__data__: + raise RegistryError(f'context already registered for protocol: {index}') + self.__data__[index] = context + + @classmethod + def make(cls, value: 'Optional[ContextRegistry | ProtocolContext | Mapping[str, ProtocolContext] | Iterable[ProtocolContext]]') -> 'Self': # pylint: disable=line-too-long + """Coerce ``value`` into a :class:`ContextRegistry`. + + This is the normalisation used by the public interfaces, so that a + caller may pass whichever shape is most convenient: + + * :data:`None` -- an empty registry; + * a :class:`ContextRegistry` -- copied as is; + * a single :class:`ProtocolContext`; + * a mapping of protocol index ID to :class:`ProtocolContext`; + * any iterable of :class:`ProtocolContext`. + + Args: + value: Value to coerce. + + Returns: + A new :class:`ContextRegistry`. + + Raises: + RegistryError: If ``value`` is of an unsupported type. + + """ + self = cls() + if value is None: + return self + + # NOTE: Copied rather than aliased, so that a caller handed a registry + # by the public ``ProtocolBase.context`` property cannot reach into the + # protocol's own. The per-layer hot path in ``ProtocolBase.__init__`` + # skips this call instead of making the copy cheaper. + if isinstance(value, ContextRegistry): + self.__data__.update(value.__data__) + return self + + if isinstance(value, ProtocolContext): + self.register(value) + return self + + if isinstance(value, collections.abc.Mapping): + for name, context in value.items(): + self.register(context, name=name) + return self + + if isinstance(value, collections.abc.Iterable): + for context in value: + self.register(context) + return self + + raise RegistryError(f'unsupported context: {value!r}') + + def match(self, names: 'Iterable[str]', + cls: 'Optional[Type[_CT]]' = None) -> 'Optional[_CT]': + """Find the context registered for any of ``names``. + + Args: + names: Protocol index IDs to look up, in order of preference. + cls: If given, the context is only returned when it is an + instance of ``cls``. + + Returns: + The first matching context, or :data:`None` if there is none. + + """ + for name in names: + context = self.__data__.get(name.upper()) + if context is None: + continue + if cls is not None and not isinstance(context, cls): + continue + return context # type: ignore[return-value] + return None + + ########################################################################## + # Data models. + ########################################################################## + + def __getitem__(self, key: 'str') -> 'ProtocolContext': + """Get the context registered for ``key``.""" + return self.__data__[key.upper()] + + def __iter__(self) -> 'Iterator[str]': + """Iterate over the registered protocol index IDs.""" + return iter(self.__data__) + + def __len__(self) -> 'int': + """Number of registered contexts.""" + return len(self.__data__) + + def __contains__(self, key: 'Any') -> 'bool': + """Test whether a context is registered for ``key``.""" + if isinstance(key, str): + return key.upper() in self.__data__ + return False + + def __bool__(self) -> 'bool': + """Test whether any context is registered.""" + return bool(self.__data__) + + def __repr__(self) -> 'str': + """Representation of the registry, free of any secrets.""" + return f'ContextRegistry({", ".join(f"{k}={v!r}" for k, v in self.__data__.items())})' diff --git a/pcapkit/foundation/engines/dpkt.py b/pcapkit/foundation/engines/dpkt.py index eea612192b..0d9f91930b 100644 --- a/pcapkit/foundation/engines/dpkt.py +++ b/pcapkit/foundation/engines/dpkt.py @@ -82,7 +82,10 @@ def run(self) -> 'None': Warns: AttributeWarning: If :attr:`self.extractor._exlyr ` and/or :attr:`self.extractor._exptl ` - is provided as the DPKT engine currently does not support such operations. + is provided as the DPKT engine currently does not support such operations; + or if :attr:`self.extractor._exctx ` + is provided, as the DPKT engine does not parse with :mod:`pcapkit`'s own + protocol implementations. Raises: FormatError: If the file format is not supported, i.e., not a PCAP @@ -100,6 +103,12 @@ def run(self) -> 'None': f"'layer={ext._exlyr}' and 'protocol={ext._exptl}' ignored", AttributeWarning, stacklevel=stacklevel()) + if ext._exctx: + warn("'Extractor(engine=dpkt)' does not parse with pcapkit's own protocol " + "implementations, so the parsing context supplied through " + "'context=' is ignored", + AttributeWarning, stacklevel=stacklevel()) + # setup verbose handler if ext._flag_v: from pcapkit.toolkit.dpkt import packet2chain # isort:skip diff --git a/pcapkit/foundation/engines/pcap.py b/pcapkit/foundation/engines/pcap.py index 6285f1ed9d..159d52bdbe 100644 --- a/pcapkit/foundation/engines/pcap.py +++ b/pcapkit/foundation/engines/pcap.py @@ -143,7 +143,8 @@ def read_frame(self) -> 'Frame': # read frame header frame = Frame(ext._ifile, num=ext._frnum+1, header=self._gbhdr.info, - layer=ext._exlyr, protocol=ext._exptl, nanosecond=self._nnsec) + layer=ext._exlyr, protocol=ext._exptl, nanosecond=self._nnsec, + __context__=ext._exctx) ext._frnum += 1 # verbose output diff --git a/pcapkit/foundation/engines/pcapng.py b/pcapkit/foundation/engines/pcapng.py index c6b09bf3a2..67aba8d19a 100644 --- a/pcapkit/foundation/engines/pcapng.py +++ b/pcapkit/foundation/engines/pcapng.py @@ -184,6 +184,7 @@ def read_frame(self) -> 'P_PCAPNG': # read next block block = P_PCAPNG(ext._ifile, num=ext._frnum+1, sct=len(self._ctx_list), ctx=self._ctx, layer=ext._exlyr, protocol=ext._exptl, + __context__=ext._exctx, __packet__={ 'snaplen': self._get_snaplen(), }) diff --git a/pcapkit/foundation/engines/pyshark.py b/pcapkit/foundation/engines/pyshark.py index bd1f9c38e4..b9f591eece 100644 --- a/pcapkit/foundation/engines/pyshark.py +++ b/pcapkit/foundation/engines/pyshark.py @@ -83,19 +83,28 @@ def run(self) -> 'None': support such operations. * if reassembly is enabled, as the PyShark engine currently does not support such operation. + * if :attr:`self.extractor._exctx ` + is provided, as the PyShark engine does not parse with + :mod:`pcapkit`'s own protocol implementations. """ ext = self._extractor if ext._exlyr != 'none' or ext._exptl != 'null': - warn("'Extractor(engine='pyshark')' does not support protocol and layer threshold; " + warn("'Extractor(engine=pyshark)' does not support protocol and layer threshold; " f"'layer={ext._exlyr}' and 'protocol={ext._exptl}' ignored", AttributeWarning, stacklevel=stacklevel()) + if ext._exctx: + warn("'Extractor(engine=pyshark)' does not parse with pcapkit's own protocol " + "implementations, so the parsing context supplied through " + "'context=' is ignored", + AttributeWarning, stacklevel=stacklevel()) + if ext._flag_r and (ext._ipv4 or ext._ipv6 or ext._tcp): ext._flag_r = False ext._reasm = ReassemblyManager(ipv4=None, ipv6=None, tcp=None) - warn("'Extractor(engine='pyshark')' object dose not support reassembly; " + warn("'Extractor(engine=pyshark)' object does not support reassembly; " f"so 'ipv4={ext._ipv4}', 'ipv6={ext._ipv6}' and 'tcp={ext._tcp}' will be ignored", AttributeWarning, stacklevel=stacklevel()) diff --git a/pcapkit/foundation/engines/scapy.py b/pcapkit/foundation/engines/scapy.py index bc52ea6536..70c177102f 100644 --- a/pcapkit/foundation/engines/scapy.py +++ b/pcapkit/foundation/engines/scapy.py @@ -77,7 +77,10 @@ def run(self) -> 'None': Warns: AttributeWarning: If :attr:`self.extractor._exlyr ` and/or :attr:`self.extractor._exptl ` - is provided as the Scapy engine currently does not support such operations. + is provided as the Scapy engine currently does not support such operations; + or if :attr:`self.extractor._exctx ` + is provided, as the Scapy engine does not parse with :mod:`pcapkit`'s own + protocol implementations. """ ext = self._extractor @@ -87,6 +90,12 @@ def run(self) -> 'None': f"'layer={ext._exlyr}' and 'protocol={ext._exptl}' ignored", AttributeWarning, stacklevel=stacklevel()) + if ext._exctx: + warn("'Extractor(engine=scapy)' does not parse with pcapkit's own protocol " + "implementations, so the parsing context supplied through " + "'context=' is ignored", + AttributeWarning, stacklevel=stacklevel()) + # setup verbose handler if ext._flag_v: from pcapkit.toolkit.scapy import packet2chain # isort:skip diff --git a/pcapkit/foundation/extraction.py b/pcapkit/foundation/extraction.py index a222c947b5..676fe200dc 100644 --- a/pcapkit/foundation/extraction.py +++ b/pcapkit/foundation/extraction.py @@ -22,6 +22,7 @@ from dictdumper.dumper import Dumper +from pcapkit.corekit.context import ContextRegistry from pcapkit.corekit.io import SeekableReader from pcapkit.corekit.module import ModuleDescriptor from pcapkit.dumpkit.common import make_dumper @@ -43,7 +44,7 @@ if TYPE_CHECKING: from io import BufferedReader from types import ModuleType, TracebackType - from typing import IO, Any, Callable, DefaultDict, Optional, Type, Union + from typing import IO, Any, Callable, DefaultDict, Iterable, Mapping, Optional, Type, Union from dpkt.dpkt import Packet as DPKTPacket from pyshark.packet.packet import Packet as PySharkPacket @@ -141,6 +142,8 @@ class Extractor(Generic[_P]): _exptl: 'Protocols' #: Extract til layer. _exlyr: 'Layers' + #: Caller supplied parsing context, c.f. :mod:`pcapkit.corekit.context`. + _exctx: 'ContextRegistry' #: Extraction engine name. _exnam: 'Engines' #: Extraction engine instance. @@ -638,7 +641,8 @@ def __init__(self, trace_byteorder: 'Literal["big", "little"]' = sys.byteorder, trace_nanosecond: 'bool' = False, # trace settings # pylint: disable=line-too-long ip: 'bool' = False, ipv4: 'bool' = False, ipv6: 'bool' = False, tcp: 'bool' = False, # reassembly/trace settings # pylint: disable=line-too-long buffer_size: 'int' = io.DEFAULT_BUFFER_SIZE, buffer_save: 'bool' = False, buffer_path: 'Optional[str]' = None, # buffer settings # pylint: disable=line-too-long - no_eof: 'bool' = False) -> 'None': + no_eof: 'bool' = False, # EOF settings # pylint: disable=line-too-long + context: 'Optional[ContextRegistry | ProtocolContext | Mapping[str, ProtocolContext] | Iterable[ProtocolContext]]' = None) -> 'None': # context settings # pylint: disable=line-too-long """Initialise PCAP Reader. Args: @@ -683,6 +687,16 @@ def __init__(self, no_eof: if raise :exc:`EOFError` when EOF + context: caller supplied parsing context for protocols that need + information not carried on the wire, keyed by protocol index + ID -- c.f. :mod:`pcapkit.corekit.context`. Accepts a + :class:`~pcapkit.corekit.context.ContextRegistry`, a single + :class:`~pcapkit.corekit.context.ProtocolContext`, a mapping, + or any iterable of contexts. The channel is honoured by the + ``default``, ``pcap`` and ``pcapng`` engines, which parse with + :mod:`pcapkit`'s own protocol implementations; the third party + engines ignore it. + Warns: pcapkit.utilities.warnings.FormatWarning: Warns under following circumstances: @@ -737,6 +751,7 @@ def __init__(self, self._exptl = protocol or 'null' # extract til protocol self._exlyr = cast('Layers', (layer or 'none').lower()) # extract til layer self._exnam = cast('Engines', (engine or 'default').lower()) # extract using engine + self._exctx = ContextRegistry.make(context) # caller supplied context if reassembly: reasm_obj_ipv4 = reasm_obj_ipv6 = reasm_obj_tcp = None diff --git a/pcapkit/interface/core.py b/pcapkit/interface/core.py index 9c6dd771c6..dbdc0213dc 100644 --- a/pcapkit/interface/core.py +++ b/pcapkit/interface/core.py @@ -22,10 +22,11 @@ from pcapkit.utilities.exceptions import FormatError if TYPE_CHECKING: - from typing import IO, Optional, Type + from typing import IO, Iterable, Mapping, Optional, Type from typing_extensions import Literal + from pcapkit.corekit.context import ContextRegistry, ProtocolContext from pcapkit.foundation.extraction import Engines, Formats, Layers, Protocols, VerboseHandler from pcapkit.foundation.reassembly.reassembly import ReassemblyBase as Reassembly from pcapkit.foundation.traceflow.traceflow import TraceFlowBase as TraceFlow @@ -67,7 +68,8 @@ def extract(fin: 'Optional[str | IO[bytes]]' = None, fout: 'Optional[str]' = Non trace_byteorder: 'Literal["big", "little"]' = sys.byteorder, trace_nanosecond: 'bool' = False, # trace settings # pylint: disable=line-too-long ip: 'bool' = False, ipv4: 'bool' = False, ipv6: 'bool' = False, tcp: 'bool' = False, # reassembly/trace settings # pylint: disable=line-too-long buffer_size: 'int' = io.DEFAULT_BUFFER_SIZE, buffer_save: 'bool' = False, buffer_path: 'Optional[str]' = None, # buffer settings # pylint: disable=line-too-long - no_eof: 'bool' = False) -> 'Extractor': + no_eof: 'bool' = False, # EOF settings # pylint: disable=line-too-long + context: 'Optional[ContextRegistry | ProtocolContext | Mapping[str, ProtocolContext] | Iterable[ProtocolContext]]' = None) -> 'Extractor': # context settings # pylint: disable=line-too-long """Extract a PCAP file. Arguments: @@ -112,6 +114,18 @@ def extract(fin: 'Optional[str | IO[bytes]]' = None, fout: 'Optional[str]' = Non no_eof: if not raise :exc:`EOFError` when reach EOF + context: caller supplied parsing context for protocols that need + information not carried on the wire, keyed by protocol index ID -- + c.f. :mod:`pcapkit.corekit.context`. The motivating case is + :class:`~pcapkit.protocols.internet.esp.ESP`, which needs the + Security Association to find the trailer and decrypt the payload:: + + >>> from pcapkit.protocols.internet.esp import (Cipher, ESPContext, + ... SecurityAssociation) + >>> sa = SecurityAssociation(spi=0x4321, encryption=Cipher.AES_CBC, + ... encryption_key=key) + >>> extraction = pcapkit.extract('esp.pcap', context=ESPContext(sa)) + Returns: An :class:`~pcapkit.foundation.extraction.Extractor` object. @@ -128,7 +142,7 @@ def extract(fin: 'Optional[str | IO[bytes]]' = None, fout: 'Optional[str]' = Non trace=trace, trace_fout=trace_fout, trace_format=trace_format, trace_byteorder=trace_byteorder, trace_nanosecond=trace_nanosecond, buffer_size=buffer_size, buffer_path=buffer_path, buffer_save=buffer_save, - no_eof=no_eof) + no_eof=no_eof, context=context) def reassemble(protocol: 'str | Type[Protocol]', strict: 'bool' = False) -> 'Reassembly': diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index e0f00ab3b6..3e61edeb55 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -54,7 +54,7 @@ 'OSPF', 'RARP', 'VLAN', # Internet Layer - 'AH', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', + 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # IPv6 Extension Header 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Opts', diff --git a/pcapkit/protocols/data/internet/__init__.py b/pcapkit/protocols/data/internet/__init__.py index 4c43445719..44866423a0 100644 --- a/pcapkit/protocols/data/internet/__init__.py +++ b/pcapkit/protocols/data/internet/__init__.py @@ -4,6 +4,9 @@ # Authentication Header from pcapkit.protocols.data.internet.ah import AH +# Encapsulating Security Payload +from pcapkit.protocols.data.internet.esp import ESP + # Host Identity Protocol from pcapkit.protocols.data.internet.hip import HIP from pcapkit.protocols.data.internet.hip import AckDataParameter as HIP_AckDataParameter @@ -216,6 +219,9 @@ # Authentication Header 'AH', + # Encapsulating Security Payload + 'ESP', + # Host Identity Protocol 'HIP', 'HIP_Control', 'HIP_LocatorData', 'HIP_Locator', 'HIP_HostIdentity', 'HIP_Lifetime', 'HIP_Flags', diff --git a/pcapkit/protocols/data/internet/esp.py b/pcapkit/protocols/data/internet/esp.py new file mode 100644 index 0000000000..0d13fb0102 --- /dev/null +++ b/pcapkit/protocols/data/internet/esp.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""data model for ESP protocol""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from typing import Optional + + from pcapkit.const.reg.transtype import TransType + from pcapkit.protocols.internet.esp import ESPStatus + +__all__ = ['ESP'] + + +@info_final +class ESP(Protocol): + """Data model for ESP protocol. + + The trailer fields (:attr:`next`, :attr:`pad_len`, :attr:`padding`) and + :attr:`plaintext` are recovered from the *decrypted* payload, and are + therefore :data:`None` whenever the payload could not be decrypted -- + :rfc:`4303` places them inside the ciphertext, so guessing them from an + encrypted payload is not possible. :attr:`status` says which of those + two cases applies, and :attr:`error` says why. + + Important: + No key material is recorded here, by design. This data model is what + :meth:`Info.to_dict ` returns + and hence what reaches the output dumpers, so the Security + Association -- and the keys it holds -- is deliberately kept out of + it. + + """ + + #: Security parameters index. + spi: 'int' + #: Sequence number field. + seq: 'int' + #: Total length of the ESP header, payload, trailer and ICV, i.e. every + #: byte of the packet that ESP owns. + length: 'int' + #: Payload data exactly as transmitted -- ciphertext, prefixed by any + #: cryptographic synchronisation data (IV) -- excluding the ICV. + payload_data: 'bytes' + #: Integrity check value as transmitted; empty when absent, or when no + #: Security Association was available to say how long it is. + icv: 'bytes' + #: Outcome of the decryption and integrity check. + status: 'ESPStatus' + #: Reason the payload was not decrypted, if it was not. + error: 'Optional[str]' + #: Next header, from the decrypted ESP trailer. + next: 'Optional[TransType]' + #: Pad length, from the decrypted ESP trailer. + pad_len: 'Optional[int]' + #: Padding bytes, from the decrypted ESP trailer. + padding: 'Optional[bytes]' + #: Decrypted payload, with the ESP trailer stripped, i.e. the next + #: layer's data. + plaintext: 'Optional[bytes]' + + if TYPE_CHECKING: + def __init__(self, spi: 'int', seq: 'int', length: 'int', payload_data: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin,too-many-arguments + icv: 'bytes', status: 'ESPStatus', error: 'Optional[str]', + next: 'Optional[TransType]', pad_len: 'Optional[int]', + padding: 'Optional[bytes]', plaintext: 'Optional[bytes]') -> 'None': ... diff --git a/pcapkit/protocols/internet/NotImplemented/esp.py b/pcapkit/protocols/internet/NotImplemented/esp.py deleted file mode 100644 index f89b0d24b4..0000000000 --- a/pcapkit/protocols/internet/NotImplemented/esp.py +++ /dev/null @@ -1,97 +0,0 @@ -# -*- coding: utf-8 -*- -"""encapsulating security payload - -``jspcap.protocols.internet.esp`` contains ``ESP`` only, -which implements Encapsulating Security Payload header -(ESP), whose structure is described as below. - - 0 1 2 3 - 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---- -| Security Parameters Index (SPI) | ^Int. -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Cov- -| Sequence Number | |ered -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | ---- -| Payload Data* (variable) | | ^ -~ ~ | | -| | |Conf. -+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Cov- -| | Padding (0-255 bytes) | |ered* -+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | -| | Pad Length | Next Header | v v -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ------ -| Integrity Check Value-ICV (variable) | -~ ~ -| | -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - -""" -from pcapkit.corekit.infoclass import Info -from pcapkit.protocols.internet.ipsec import IPsec - -__all__ = ['ESP'] - - -class ESP(IPsec): - """This class implements Encapsulating Security Payload.""" - - ########################################################################## - # Properties. - ########################################################################## - - @property - def name(self): - """Name of corresponding protocol.""" - return 'Encapsulating Security Payload' - - @property - def length(self): - """"Header length of current protocol.""" - return self._info.length - - @property - def protocol(self): - """Name of next layer protocol.""" - return self._info.next - - ########################################################################## - # Methods. - ########################################################################## - - def read(self, length, version): - """Read Encapsulating Security Payload. - - Structure of ESP header [RFC 4303]: - - 0 1 2 3 - 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---- - | Security Parameters Index (SPI) | ^Int. - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Cov- - | Sequence Number | |ered - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | ---- - | Payload Data* (variable) | | ^ - ~ ~ | | - | | |Conf. - + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Cov- - | | Padding (0-255 bytes) | |ered* - +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | - | | Pad Length | Next Header | v v - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ------ - | Integrity Check Value-ICV (variable) | - ~ ~ - | | - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - - """ - pass - - ########################################################################## - # Data models. - ########################################################################## - - def __len__(self): - return self._info.length - - def __length_hint__(self): - return 256 diff --git a/pcapkit/protocols/internet/__init__.py b/pcapkit/protocols/internet/__init__.py index d1aa609b46..e00b2deb73 100644 --- a/pcapkit/protocols/internet/__init__.py +++ b/pcapkit/protocols/internet/__init__.py @@ -9,13 +9,14 @@ in internet layer, with detailed implementation and methods. """ -# TODO: Implements ECN, ESP, ICMP, ICMPv6, IGMP, Shim6. +# TODO: Implements ECN, ICMP, ICMPv6, IGMP, Shim6. # Base Class for Internet Layer from pcapkit.protocols.internet.internet import Internet # Utility Classes for Protocols from pcapkit.protocols.internet.ah import AH +from pcapkit.protocols.internet.esp import ESP from pcapkit.protocols.internet.ipv4 import IPv4 from pcapkit.protocols.internet.ipv6 import IPv6 from pcapkit.protocols.internet.ipx import IPX @@ -37,7 +38,7 @@ __all__ = [ 'ETHERTYPE', # Protocol Numbers - 'AH', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer + 'AH', 'ESP', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', # Internet Layer 'HIP', 'HOPOPT', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', # IPv6 Extension Header ] diff --git a/pcapkit/protocols/internet/esp.py b/pcapkit/protocols/internet/esp.py new file mode 100644 index 0000000000..cbf1b60fae --- /dev/null +++ b/pcapkit/protocols/internet/esp.py @@ -0,0 +1,1413 @@ +# -*- coding: utf-8 -*- +"""ESP - Encapsulating Security Payload +========================================== + +.. module:: pcapkit.protocols.internet.esp + +:mod:`pcapkit.protocols.internet.esp` contains +:class:`~pcapkit.protocols.internet.esp.ESP` only, +which implements extractor for Encapsulating +Security Payload (ESP) [*]_, whose structure is +described as below: + +======= ========= ===================== ============================================== +Octets Bits Name Description +======= ========= ===================== ============================================== + 0 0 ``esp.spi`` Security Parameters Index (SPI) + 4 32 ``esp.seq`` Sequence Number + 8 64 ``esp.payload_data`` Payload Data (variable, encrypted) + ? ? Padding (0-255 bytes, encrypted) + ? ? ``esp.pad_len`` Pad Length (encrypted) + ? ? ``esp.next`` Next Header (encrypted) + ? ? ``esp.icv`` Integrity Check Value (ICV, variable) +======= ========= ===================== ============================================== + +Unlike every other protocol in :mod:`pcapkit`, ESP is **not** self +describing. :rfc:`4303` places the ``Pad Length`` and ``Next Header`` +fields *inside* the ciphertext, and leaves the length of the ``Integrity +Check Value`` to be determined by the Security Association (SA), which is +negotiated out of band. Therefore: + +* **Without** SA context, :class:`ESP` parses the ``SPI`` and ``Sequence + Number``, reports the remainder as an opaque encrypted payload, and says + so through :attr:`esp.status `. + It does *not* guess at the trailer, and it does not raise. +* **With** SA context, :class:`ESP` splits off the ICV, verifies integrity, + decrypts, strips the padding using ``Pad Length``, and dispatches the + recovered plaintext to the next layer using ``Next Header`` -- so an + ESP tunnelled TCP segment decodes as TCP. + +SA context is supplied through the generic, protocol keyed context channel +in :mod:`pcapkit.corekit.context`: + +.. code-block:: python + + import pcapkit + from pcapkit.protocols.internet.esp import (Cipher, ESPContext, Integrity, + SecurityAssociation) + + sa = SecurityAssociation( + spi=0x4321, + encryption=Cipher.AES_CBC, + encryption_key=bytes.fromhex('90d382b410eeba7ad938c46cec1a82bf'), + integrity=Integrity.HMAC_SHA2_256_128, + integrity_key=bytes.fromhex('00' * 32), + destination='192.168.123.100', # optional, disambiguates several tunnels + ) + extraction = pcapkit.extract('esp.pcap', context=ESPContext(sa)) + +Registered algorithms, and supported ones +----------------------------------------- + +ESP has no algorithm registry of its own -- an SA's algorithms are negotiated +by IKEv2 -- so :class:`Cipher ` and +:class:`Integrity ` are generated from +the IKEv2 *transform ID* sub-registries and enumerate everything **IANA has +registered**: 3DES, AES-CTR, the AES-CCM and Camellia families, +ChaCha20-Poly1305, the implicit IV variants of :rfc:`8750`, the :rfc:`9227` +MGM suites, and the transforms long since deprecated. + +**Registration is not support.** A member of either enumeration says only that +IANA assigned the transform an ID; what :mod:`pcapkit` can actually apply is +the separate, explicit :data:`CIPHER_SUITES` and :data:`INTEGRITY_SUITES` +tables, and :meth:`CipherSuite.get` / :meth:`IntegritySuite.get` refuse +anything outside them rather than half-working: + +.. code-block:: python + + >>> Cipher.get('ENCR_3DES') # registered, so the enum has it + + >>> CipherSuite.get('ENCR_3DES') # but ESP cannot apply it + Traceback (most recent call last): + ... + pcapkit.utilities.exceptions.ProtocolError: unsupported ESP encryption + algorithm: ENCR_3DES; pcapkit implements ENCR_NULL, ENCR_AES_CBC, + ENCR_AES_GCM_8, ENCR_AES_GCM_12, ENCR_AES_GCM_16 + +Decryption requires the optional |cryptography|_ dependency +(``pip install pypcapkit[crypto]``). :mod:`pcapkit` imports and works +without it; an SA that names an AES suite simply degrades to the opaque +payload path, with a warning. + +The supported set is anchored on the *mandatory to implement* algorithms of +:rfc:`8221`. The rows marked ``yes`` are exactly the keys of +:data:`CIPHER_SUITES`; everything else the registry lists is enumerated and +rejected. + +============================ =================== ============ ================================== +Encryption :rfc:`8221` status Implemented Notes +============================ =================== ============ ================================== +``ENCR_NULL`` MUST yes :rfc:`2410`; needs no ``cryptography`` +``ENCR_AES_CBC`` MUST yes :rfc:`3602`; 128/192/256-bit keys +``ENCR_AES_GCM_16`` MUST yes :rfc:`4106`; 8-octet explicit IV +``ENCR_AES_GCM_8`` -- yes :rfc:`4106`, 8-octet ICV +``ENCR_AES_GCM_12`` -- yes :rfc:`4106`, 12-octet ICV +``ENCR_AES_CCM_8`` SHOULD **no** registered, not implemented +``ENCR_CHACHA20_POLY1305`` SHOULD **no** registered, not implemented +``ENCR_3DES`` SHOULD NOT **no** registered, deliberately omitted +DES, Blowfish, 3IDEA MUST NOT **no** registered, deliberately omitted +============================ =================== ============ ================================== + +"DES, Blowfish, 3IDEA" above covers ``ENCR_DES``, ``ENCR_DES_IV64``, +``ENCR_DES_IV32``, ``ENCR_BLOWFISH`` and ``ENCR_3IDEA``. + +Likewise, the rows marked ``yes`` below are exactly the keys of +:data:`INTEGRITY_SUITES`: + +============================ =================== ============ ================================== +Integrity :rfc:`8221` status Implemented Notes +============================ =================== ============ ================================== +``NONE`` MUST (AEAD only) yes for AEAD suites +``AUTH_HMAC_SHA2_256_128`` MUST yes :rfc:`4868` +``AUTH_HMAC_SHA2_512_256`` SHOULD yes :rfc:`4868` +``AUTH_HMAC_SHA2_384_192`` -- yes :rfc:`4868` +``AUTH_HMAC_SHA1_96`` MUST- yes :rfc:`2404`; still widely captured +``AUTH_AES_XCBC_96`` SHOULD / MAY **no** registered, not implemented +``AUTH_AES_*_GMAC`` MAY **no** registered, not implemented +MD5, DES-MAC, KPDK-MD5 MUST NOT **no** registered, deliberately omitted +============================ =================== ============ ================================== + +"MD5, DES-MAC, KPDK-MD5" above covers ``AUTH_HMAC_MD5_96``, +``AUTH_HMAC_MD5_128``, ``AUTH_DES_MAC`` and ``AUTH_KPDK_MD5``. The registry +spells the "no integrity algorithm" transform ``NONE`` rather than +``AUTH_NONE``, and :attr:`Integrity.NONE +` follows it. + +Both enumerations additionally carry each transform's prefix-stripped spelling +as an alias, since that is how ESP and :rfc:`8221` name the algorithms, so +:attr:`Cipher.AES_CBC ` and +:attr:`Cipher.ENCR_AES_CBC ` +are the same member. + +Known limitations +----------------- + +* **Extended Sequence Numbers (ESN,** :rfc:`4303` **§2.2.1) are not + supported.** The high-order 32 bits of an ESN are not transmitted, and a + stateless parser cannot recover them; they are required both for the ICV + computation and for the AEAD associated data. An ESN protected packet + therefore fails the integrity check *cleanly* rather than being decoded. +* **Traffic Flow Confidentiality (TFC) padding (§2.4) is not detected.** + TFC padding is indistinguishable from real payload without inspecting the + inner protocol's own length field, so it is handed to the next layer as + part of the plaintext. +* **Anti-replay is not performed.** :mod:`pcapkit` is an analyser, not a + receiver; the sequence number is reported, never checked. +* The ICV is *verified* but a failure is reported rather than raised, so + that one bad packet does not abort a capture. + +.. |cryptography| replace:: ``cryptography`` +.. _cryptography: https://cryptography.io + +.. [*] https://en.wikipedia.org/wiki/IPsec + +""" +import enum +import hashlib +import hmac +import ipaddress +import os +from typing import TYPE_CHECKING, NamedTuple, overload + +from pcapkit.const.esp.cipher import Cipher +from pcapkit.const.esp.integrity import Integrity +from pcapkit.const.reg.transtype import TransType as Enum_TransType +from pcapkit.corekit.context import ProtocolContext +from pcapkit.protocols.data.internet.esp import ESP as Data_ESP +from pcapkit.protocols.internet.ipsec import IPsec +from pcapkit.protocols.schema.internet.esp import ESP as Schema_ESP +from pcapkit.protocols.schema.schema import Schema +from pcapkit.utilities.exceptions import ProtocolError, ProtocolUnbound +from pcapkit.utilities.warnings import ProtocolWarning, warn + +__all__ = ['ESP', 'ESPStatus', 'Cipher', 'Integrity', 'CipherSuite', 'IntegritySuite', + 'CIPHER_SUITES', 'INTEGRITY_SUITES', 'SecurityAssociation', 'ESPContext'] + +if TYPE_CHECKING: + from enum import IntEnum as StdlibEnum + from ipaddress import IPv4Address, IPv6Address + from typing import IO, Any, Optional, Type + + from aenum import IntEnum as AenumEnum + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase as Protocol + +#: Cached :mod:`cryptography` primitives, c.f. :func:`load_cryptography`. +#: :data:`NotImplemented` means the import has not been attempted yet, and +#: :data:`None` that it was attempted and |cryptography|_ is not installed -- +#: three states, so a missing dependency is not retried on every frame. +_CRYPTO = NotImplemented # type: Any + + +def load_cryptography() -> 'Optional[tuple[Any, Any, Any, Type[Exception]]]': + """Load the optional |cryptography|_ primitives. + + Returns: + A 4-tuple of ``(Cipher, algorithms, modes, InvalidTag)`` taken from + :mod:`cryptography.hazmat.primitives.ciphers` and + :mod:`cryptography.exceptions`, or :data:`None` when + |cryptography|_ is not installed. + + Notes: + The import is attempted at most once and the outcome is cached, so + that a capture full of ESP packets does not pay for a failing import + on every frame. + + """ + global _CRYPTO # pylint: disable=global-statement + + if _CRYPTO is NotImplemented: + try: + from cryptography.exceptions import \ + InvalidTag as _InvalidTag # pylint: disable=import-outside-toplevel + from cryptography.hazmat.primitives.ciphers import \ + Cipher as _CryptoCipher # pylint: disable=import-outside-toplevel + from cryptography.hazmat.primitives.ciphers import \ + algorithms as _crypto_algorithms # pylint: disable=import-outside-toplevel + from cryptography.hazmat.primitives.ciphers import \ + modes as _crypto_modes # pylint: disable=import-outside-toplevel + except ImportError: + _CRYPTO = None + else: + _CRYPTO = (_CryptoCipher, _crypto_algorithms, _crypto_modes, _InvalidTag) + return _CRYPTO + + +############################################################################## +# Algorithm support. +############################################################################## + + +def _resolve(registry: 'Type[Cipher] | Type[Integrity]', value: 'Any', + prefix: 'str', kind: 'str') -> 'Any': + """Resolve ``value`` to a member of an IKEv2 transform registry. + + Args: + registry: :class:`Cipher ` or + :class:`Integrity `. + value: A member of ``registry``, an IKEv2 transform ID, or a + transform name. + prefix: Prefix the registry spells its names with, i.e. ``'ENCR_'`` + or ``'AUTH_'``. + kind: Word naming the registry for the error message, i.e. + ``'encryption'`` or ``'integrity'``. + + Returns: + The corresponding member of ``registry``. + + Raises: + ProtocolError: If ``value`` names nothing the registry holds. + + Notes: + Both enumerations carry each transform's prefix-stripped spelling as + an alias, so a plain lookup already accepts ``'AES_CBC'`` as well as + ``'ENCR_AES_CBC'``. ``prefix`` is still needed for the few names that + are no Python identifier once stripped, and so have no alias -- + ``ENCR_3DES`` and ``ENCR_3IDEA``. + + Resolution is deliberately separate from *support*: it answers only + whether IANA registered the transform. See :meth:`CipherSuite.get`. + + """ + if isinstance(value, registry): + return value + if isinstance(value, int): + try: + return registry(value) + except ValueError: + raise ProtocolError(f'unknown ESP {kind} algorithm: {value}') from None + + name = str(value).upper().replace('-', '_') + for candidate in (name, f'{prefix}{name}'): + if candidate in registry.__members__: + return registry[candidate] + raise ProtocolError(f'unknown ESP {kind} algorithm: {value!r}') + + +class CipherSuite(NamedTuple): + """Parameters of an ESP encryption algorithm :mod:`pcapkit` implements. + + A member of :class:`Cipher ` records only + that IANA registered the transform. This records how to apply it, and + membership of :data:`CIPHER_SUITES` is what "supported" means -- see the + module docstring. + + """ + + #: Encryption algorithm the suite describes. + cipher: 'Cipher' + #: Whether the algorithm is a combined mode (AEAD) algorithm, i.e. one + #: that provides its own integrity protection. + is_aead: 'bool' + #: Length of the explicit IV carried at the head of the payload data. + iv_length: 'int' + #: Cipher block size, in octets. :rfc:`4303` §2.4 additionally requires + #: the ciphertext to be a multiple of 4 octets, which is why + #: :meth:`ESP.make` aligns to ``max(block_size, 4)`` rather than to this + #: value alone. + block_size: 'int' + #: Length of the ICV the algorithm itself produces (AEAD only). + icv_length: 'int' + #: Permitted lengths of the key, in octets, excluding any salt. + key_sizes: 'tuple[int, ...]' + #: Length of the salt taken from the keying material [:rfc:`4106` §8.1]. + salt_length: 'int' + #: Whether the algorithm needs the optional |cryptography|_ dependency. + requires_cryptography: 'bool' + + @classmethod + def get(cls, value: 'Cipher | str | int') -> 'CipherSuite': + """Look up how to apply an encryption algorithm. + + Args: + value: A :class:`Cipher ` member, + an IKEv2 transform ID, or a name such as ``'AES-CBC'``, + ``'aes_cbc'`` or ``'ENCR_AES_CBC'``. + + Returns: + The suite describing how to apply the algorithm. + + Raises: + ProtocolError: If ``value`` names no registered algorithm, or + names one :mod:`pcapkit` does not implement. The registry is + far larger than the set of suites here, so the second case is + the common one. + + """ + cipher = _resolve(Cipher, value, 'ENCR_', 'encryption') + suite = CIPHER_SUITES.get(cipher) + if suite is None: + raise ProtocolError(f'unsupported ESP encryption algorithm: {cipher.name}; pcapkit ' + f'implements {", ".join(key.name for key in CIPHER_SUITES)}') + return suite + + +class IntegritySuite(NamedTuple): + """Parameters of an ESP integrity algorithm :mod:`pcapkit` implements. + + As with :class:`CipherSuite`, membership of :data:`INTEGRITY_SUITES` -- + not membership of the IANA registry -- is what makes an algorithm + supported. + + """ + + #: Integrity algorithm the suite describes. + integrity: 'Integrity' + #: Name of the underlying hash, for :func:`hmac.new`, or :data:`None` when + #: the algorithm computes no ICV of its own. + digest: 'Optional[str]' + #: Length of the truncated ICV, in octets. + icv_length: 'int' + #: Key length required by the specification, in octets. + key_size: 'int' + + @classmethod + def get(cls, value: 'Integrity | str | int') -> 'IntegritySuite': + """Look up how to apply an integrity algorithm. + + Args: + value: An :class:`Integrity + ` member, an IKEv2 + transform ID, or a name such as ``'HMAC-SHA-256-128'``, + ``'hmac_sha2_256_128'`` or ``'AUTH_HMAC_SHA2_256_128'``. + + Returns: + The suite describing how to apply the algorithm. + + Raises: + ProtocolError: If ``value`` names no registered algorithm, or + names one :mod:`pcapkit` does not implement. + + """ + if isinstance(value, str): + # accept the RFC 4868 spelling ``HMAC_SHA_256_128`` as well as the + # IKEv2 spelling ``HMAC_SHA2_256_128`` + name = value.upper().replace('-', '_').replace('HMAC_SHA_', 'HMAC_SHA2_') + if name in ('HMAC_SHA2_1_96', 'HMAC_SHA2_1'): + name = 'HMAC_SHA1_96' + value = name + + integrity = _resolve(Integrity, value, 'AUTH_', 'integrity') + suite = INTEGRITY_SUITES.get(integrity) + if suite is None: + raise ProtocolError(f'unsupported ESP integrity algorithm: {integrity.name}; pcapkit ' + f'implements {", ".join(key.name for key in INTEGRITY_SUITES)}') + return suite + + +#: Encryption algorithms :mod:`pcapkit` implements, keyed by IKEv2 transform. +#: This table -- not :class:`Cipher `, which +#: is the whole IANA registry -- defines what an SA may name. +CIPHER_SUITES = { + Cipher.ENCR_NULL: CipherSuite( + cipher=Cipher.ENCR_NULL, is_aead=False, iv_length=0, block_size=1, icv_length=0, + key_sizes=(0,), salt_length=0, requires_cryptography=False, + ), + Cipher.ENCR_AES_CBC: CipherSuite( + cipher=Cipher.ENCR_AES_CBC, is_aead=False, iv_length=16, block_size=16, icv_length=0, + key_sizes=(16, 24, 32), salt_length=0, requires_cryptography=True, + ), + Cipher.ENCR_AES_GCM_8: CipherSuite( + cipher=Cipher.ENCR_AES_GCM_8, is_aead=True, iv_length=8, block_size=1, icv_length=8, + key_sizes=(16, 24, 32), salt_length=4, requires_cryptography=True, + ), + Cipher.ENCR_AES_GCM_12: CipherSuite( + cipher=Cipher.ENCR_AES_GCM_12, is_aead=True, iv_length=8, block_size=1, icv_length=12, + key_sizes=(16, 24, 32), salt_length=4, requires_cryptography=True, + ), + Cipher.ENCR_AES_GCM_16: CipherSuite( + cipher=Cipher.ENCR_AES_GCM_16, is_aead=True, iv_length=8, block_size=1, icv_length=16, + key_sizes=(16, 24, 32), salt_length=4, requires_cryptography=True, + ), +} # type: dict[Cipher, CipherSuite] + +#: Integrity algorithms :mod:`pcapkit` implements, keyed by IKEv2 transform. +#: :attr:`Integrity.NONE ` is here +#: because "no separate integrity algorithm" is a supported configuration -- +#: it is what an AEAD suite, and an unprotected SA, use. +INTEGRITY_SUITES = { + Integrity.NONE: IntegritySuite( + integrity=Integrity.NONE, digest=None, icv_length=0, key_size=0, + ), + Integrity.AUTH_HMAC_SHA1_96: IntegritySuite( + integrity=Integrity.AUTH_HMAC_SHA1_96, digest='sha1', icv_length=12, key_size=20, + ), + Integrity.AUTH_HMAC_SHA2_256_128: IntegritySuite( + integrity=Integrity.AUTH_HMAC_SHA2_256_128, digest='sha256', icv_length=16, key_size=32, + ), + Integrity.AUTH_HMAC_SHA2_384_192: IntegritySuite( + integrity=Integrity.AUTH_HMAC_SHA2_384_192, digest='sha384', icv_length=24, key_size=48, + ), + Integrity.AUTH_HMAC_SHA2_512_256: IntegritySuite( + integrity=Integrity.AUTH_HMAC_SHA2_512_256, digest='sha512', icv_length=32, key_size=64, + ), +} # type: dict[Integrity, IntegritySuite] + + +############################################################################## +# Payload processing status. +############################################################################## + + +class ESPStatus(enum.IntEnum): + """Outcome of ESP payload processing.""" + + #: The payload was decrypted and its trailer recovered. + DECRYPTED = 0 + #: No Security Association matched the packet's SPI, so the payload is + #: reported as opaque ciphertext. This is the expected state for a + #: capture taken without keys, and is **not** an error. + NO_SA = 1 + #: A Security Association matched, but the ICV did not verify. + AUTH_FAILED = 2 + #: A Security Association matched and the packet was authentic (or + #: unauthenticated), but decryption did not yield a self consistent + #: :rfc:`4303` trailer -- most commonly a wrong encryption key. + DECRYPT_FAILED = 3 + #: The packet is shorter than the Security Association says it must be. + TRUNCATED = 4 + #: A Security Association matched but its algorithms cannot be applied, + #: e.g. because the optional |cryptography|_ dependency is missing. + UNSUPPORTED = 5 + + +############################################################################## +# Security Association. +############################################################################## + + +class SecurityAssociation: + """An inbound IPsec Security Association, as far as ESP parsing needs one. + + Args: + spi: Security Parameters Index the SA applies to; :data:`None` + matches any SPI, which is convenient for a capture holding a + single tunnel. + encryption: Encryption algorithm, c.f. :meth:`CipherSuite.get`. Must + be one :mod:`pcapkit` implements; being in the IANA registry is + not enough. + encryption_key: Encryption keying material. For an AEAD suite this + is the AES key followed by the 4-octet salt [:rfc:`4106` §8.1], + unless ``salt`` is given separately. + salt: AEAD salt, when not appended to ``encryption_key``. + integrity: Integrity algorithm, c.f. :meth:`IntegritySuite.get`. Must + be :attr:`Integrity.NONE` for an AEAD suite, which provides its own. + integrity_key: Integrity key. + icv_length: Override for the ICV length, in octets. Needed for the + long standing implementation bug noted in :rfc:`8221` §6, where + ``AUTH_HMAC_SHA2_256_128`` is truncated to 96 rather than 128 + bits. + destination: Outer destination address the SA applies to. IPsec keys + an SA by ``(SPI, destination, protocol)``, and supplying the + address is what lets several tunnels sharing an SPI be told + apart. Matched only when the outer destination is known to + :mod:`pcapkit`; see :meth:`ESP.read`. + strict: Whether a padding pattern that does not follow the + monotonically increasing sequence of :rfc:`4303` §2.4 should be + treated as a decryption failure. Only applied when nothing else + authenticated the packet, since a verified ICV or AEAD tag is a + far better signal than the padding is. Some implementations pad + with zeros; set to :data:`False` for those. + + Raises: + ProtocolError: If the algorithms or key lengths are inconsistent. + + Important: + Key material is held in *private* attributes of this object, and is + exposed only through :attr:`encryption_key` / :attr:`integrity_key`. + It is deliberately absent from :meth:`~object.__repr__`, and it is + never copied into the ESP data model, which is the only thing that + reaches :meth:`Info.to_dict ` + and hence the output dumpers. + + """ + + def __init__(self, spi: 'Optional[int]' = None, *, + encryption: 'Cipher | str | int' = Cipher.ENCR_NULL, + encryption_key: 'bytes' = b'', + salt: 'Optional[bytes]' = None, + integrity: 'Integrity | str | int' = Integrity.NONE, + integrity_key: 'bytes' = b'', + icv_length: 'Optional[int]' = None, + destination: 'Optional[IPv4Address | IPv6Address | str | int | bytes]' = None, + strict: 'bool' = True) -> 'None': + if spi is not None and not 0 <= spi <= 0xFFFFFFFF: + raise ProtocolError(f'invalid SPI: {spi}') + + #: Optional[int]: Security Parameters Index, or :data:`None` for any. + self.spi = spi + #: CipherSuite: How to apply the encryption algorithm. + self.cipher_suite = CipherSuite.get(encryption) + #: IntegritySuite: How to apply the integrity algorithm. + self.integrity_suite = IntegritySuite.get(integrity) + #: Cipher: Encryption algorithm, i.e. its IKEv2 transform. + self.encryption = self.cipher_suite.cipher + #: Integrity: Integrity algorithm, i.e. its IKEv2 transform. + self.integrity = self.integrity_suite.integrity + #: bool: Whether to enforce the :rfc:`4303` §2.4 padding pattern. + self.strict = strict + #: Optional[IPv4Address | IPv6Address]: Outer destination address. + self.destination = ipaddress.ip_address(destination) if destination is not None else None + + if self.cipher_suite.is_aead and self.integrity is not Integrity.NONE: + raise ProtocolError( + f'{self.encryption.name} is a combined mode algorithm and provides its own ' + f'integrity; {self.integrity.name} must not be configured alongside it' + ) + + key, self.__salt__ = self._split_key(self.cipher_suite, encryption_key, salt) + self.__key__ = key + self.__integrity_key__ = bytes(integrity_key) + + if self.integrity is not Integrity.NONE: + expected = self.integrity_suite.key_size + if len(self.__integrity_key__) != expected: + warn(f'{self.integrity.name} expects a {expected}-octet key, got ' + f'{len(self.__integrity_key__)} octets; the ICV will very likely ' + f'fail to verify', ProtocolWarning) + + if icv_length is not None and icv_length < 0: + raise ProtocolError(f'invalid ICV length: {icv_length}') + self.__icv_length__ = icv_length + + unavailable = self.unavailable() + if unavailable is not None: + warn(f'{unavailable}; ESP payloads for SPI ' + f'{"any" if self.spi is None else f"{self.spi:#010x}"} will be reported as ' + f'opaque ciphertext', ProtocolWarning) + + ########################################################################## + # Properties. + ########################################################################## + + @property + def encryption_key(self) -> 'bytes': + """Encryption key, excluding any AEAD salt.""" + return self.__key__ + + @property + def salt(self) -> 'bytes': + """AEAD salt.""" + return self.__salt__ + + @property + def integrity_key(self) -> 'bytes': + """Integrity key.""" + return self.__integrity_key__ + + @property + def icv_length(self) -> 'int': + """Length of the ICV field carried on the wire, in octets.""" + if self.__icv_length__ is not None: + return self.__icv_length__ + if self.cipher_suite.is_aead: + return self.cipher_suite.icv_length + return self.integrity_suite.icv_length + + @property + def authenticated(self) -> 'bool': + """Whether the SA provides any integrity protection at all.""" + return self.cipher_suite.is_aead or self.integrity is not Integrity.NONE + + ########################################################################## + # Methods. + ########################################################################## + + def matches(self, spi: 'int', + destination: 'Optional[IPv4Address | IPv6Address]' = None) -> 'int': + """Score how well the SA matches a packet. + + Args: + spi: SPI read from the packet. + destination: Outer destination address, if known. + + Returns: + A non-negative score, where a higher score is a better match, or + ``-1`` when the SA does not apply at all. An SA pinned to this + exact SPI outranks a wildcard one, and an SA whose destination + was confirmed outranks one whose destination is unconstrained. + + """ + if self.spi is not None and self.spi != spi: + return -1 + + score = 2 if self.spi is not None else 0 + if self.destination is not None: + if destination is None: + # The outer destination is unknown here, so the constraint + # cannot be confirmed; treat the SA as a weaker candidate + # rather than discarding it. + return score + if self.destination != destination: + return -1 + score += 1 + return score + + def unavailable(self) -> 'Optional[str]': + """Say whether the SA's algorithms can be applied at all. + + This is checked before a packet is processed, so that a missing + optional dependency is reported as a configuration problem once per + packet rather than raised as an error from + :meth:`decrypt` -- it is not a defect in the packet. + + Returns: + A reason the SA cannot be applied, or :data:`None` when it can. + + """ + if self.cipher_suite.requires_cryptography and load_cryptography() is None: + return (f'{self.encryption.name} needs the optional "cryptography" dependency, ' + f'which is not installed (pip install pypcapkit[crypto])') + return None + + def compute_icv(self, spi: 'int', seq: 'int', body: 'bytes') -> 'bytes': + """Compute the ICV over the integrity protected part of the packet. + + The integrity computation of :rfc:`4303` §2.8 covers the SPI, the + Sequence Number, the payload data (including any explicit IV) and + the explicit ESP trailer -- that is, everything transmitted except + the ICV itself. + + Args: + spi: Security Parameters Index. + seq: Sequence number. + body: Payload data and ESP trailer, as transmitted. + + Returns: + The truncated ICV. + + Raises: + ProtocolError: If the SA has no separate integrity algorithm. + + """ + digest = self.integrity_suite.digest + if digest is None: + raise ProtocolError(f'{self.integrity.name} computes no ICV') + + mac = hmac.new(self.__integrity_key__, + spi.to_bytes(4, 'big') + seq.to_bytes(4, 'big') + body, + getattr(hashlib, digest)) + return mac.digest()[:self.icv_length] + + def decrypt(self, spi: 'int', seq: 'int', body: 'bytes', icv: 'bytes') -> 'bytes': + """Decrypt the payload data of an ESP packet. + + Args: + spi: Security Parameters Index. + seq: Sequence number. + body: Payload data as transmitted, i.e. the explicit IV (if the + algorithm uses one) followed by the ciphertext. + icv: ICV as transmitted; for an AEAD algorithm this is the + authentication tag and is an input to the decryption. + + Returns: + The plaintext, i.e. the inner payload followed by the ESP + trailer (padding, pad length, next header). + + Raises: + ProtocolError: If the payload is malformed for the algorithm, or + if |cryptography|_ is needed and unavailable. + cryptography.exceptions.InvalidTag: If an AEAD tag fails to + verify -- typically a wrong key. + + """ + cipher = self.encryption + suite = self.cipher_suite + if cipher is Cipher.ENCR_NULL: + return body + + crypto = load_cryptography() + if crypto is None: + raise ProtocolError(f'{cipher.name} needs the optional "cryptography" dependency, ' + f'which is not installed') + crypto_cipher, algorithms, modes, _ = crypto + + iv_length = suite.iv_length + if len(body) < iv_length: + raise ProtocolError(f'ESP payload is {len(body)} octets, too short for the ' + f'{iv_length}-octet {cipher.name} IV') + iv, ciphertext = body[:iv_length], body[iv_length:] + + if cipher is Cipher.ENCR_AES_CBC: + if not ciphertext or len(ciphertext) % suite.block_size: + raise ProtocolError(f'ESP ciphertext of {len(ciphertext)} octets is not a ' + f'positive multiple of the {suite.block_size}-octet ' + f'{cipher.name} block size') + decryptor = crypto_cipher(algorithms.AES(self.__key__), modes.CBC(iv)).decryptor() + return decryptor.update(ciphertext) + decryptor.finalize() + + # AEAD, i.e. AES-GCM [RFC 4106]: the nonce is the salt from the + # keying material followed by the explicit IV, and the associated + # data is the SPI and the sequence number. + if not icv: + raise ProtocolError(f'{cipher.name} requires an authentication tag, but the ' + f'packet carries no ICV') + nonce = self.__salt__ + iv + aad = spi.to_bytes(4, 'big') + seq.to_bytes(4, 'big') + decryptor = crypto_cipher( + algorithms.AES(self.__key__), + modes.GCM(nonce, icv, min_tag_length=len(icv)), + ).decryptor() + decryptor.authenticate_additional_data(aad) + return decryptor.update(ciphertext) + decryptor.finalize() + + def encrypt(self, spi: 'int', seq: 'int', plaintext: 'bytes', + iv: 'Optional[bytes]' = None) -> 'tuple[bytes, bytes]': + """Encrypt the payload data of an ESP packet. + + This is the inverse of :meth:`decrypt`, used by :meth:`ESP.make`. + + Args: + spi: Security Parameters Index. + seq: Sequence number. + plaintext: Inner payload followed by the ESP trailer. + iv: Explicit IV; a random one is generated when not given. + + Returns: + A 2-tuple of the payload data as it goes on the wire (explicit + IV followed by ciphertext) and the AEAD tag, which is empty for + a non-AEAD algorithm. + + Raises: + ProtocolError: If ``iv`` is of the wrong length, or if + |cryptography|_ is needed and unavailable. + + """ + cipher = self.encryption + suite = self.cipher_suite + if cipher is Cipher.ENCR_NULL: + return plaintext, b'' + + crypto = load_cryptography() + if crypto is None: + raise ProtocolError(f'{cipher.name} needs the optional "cryptography" dependency, ' + f'which is not installed') + crypto_cipher, algorithms, modes, _ = crypto + + iv_length = suite.iv_length + if iv is None: + iv = os.urandom(iv_length) + elif len(iv) != iv_length: + raise ProtocolError(f'{cipher.name} needs a {iv_length}-octet IV, got {len(iv)}') + + if cipher is Cipher.ENCR_AES_CBC: + encryptor = crypto_cipher(algorithms.AES(self.__key__), modes.CBC(iv)).encryptor() + return iv + encryptor.update(plaintext) + encryptor.finalize(), b'' + + nonce = self.__salt__ + iv + aad = spi.to_bytes(4, 'big') + seq.to_bytes(4, 'big') + encryptor = crypto_cipher(algorithms.AES(self.__key__), modes.GCM(nonce)).encryptor() + encryptor.authenticate_additional_data(aad) + ciphertext = encryptor.update(plaintext) + encryptor.finalize() + return iv + ciphertext, encryptor.tag[:self.icv_length] + + ########################################################################## + # Utilities. + ########################################################################## + + @staticmethod + def _split_key(suite: 'CipherSuite', material: 'bytes', + salt: 'Optional[bytes]') -> 'tuple[bytes, bytes]': + """Split keying material into the key and the AEAD salt. + + Args: + suite: Encryption algorithm parameters. + material: Keying material as supplied by the caller. + salt: Explicit salt, if the caller kept it separate. + + Returns: + A 2-tuple of the key and the salt. + + Raises: + ProtocolError: If the lengths do not match the algorithm. + + """ + material = bytes(material) + salt_length = suite.salt_length + + if salt is None: + # RFC 4106 s8.1: the last four octets of the keying material are + # the salt value. + if salt_length and len(material) > salt_length: + material, salt = material[:-salt_length], material[-salt_length:] + else: + salt = b'' + else: + salt = bytes(salt) + + if len(salt) != salt_length: + raise ProtocolError(f'{suite.cipher.name} needs a {salt_length}-octet salt, ' + f'got {len(salt)}') + if len(material) not in suite.key_sizes: + raise ProtocolError(f'{suite.cipher.name} needs a key of ' + f'{" or ".join(map(str, suite.key_sizes))} octets, ' + f'got {len(material)}') + return material, salt + + ########################################################################## + # Data models. + ########################################################################## + + def __repr__(self) -> 'str': + """Representation of the SA, free of any key material.""" + spi = 'any' if self.spi is None else f'{self.spi:#010x}' + dst = '' if self.destination is None else f' dst={self.destination!s}' + return (f'') + + +class ESPContext(ProtocolContext): + """Caller supplied Security Association context for :class:`ESP`. + + Args: + *associations: Security Associations to make available to the + parser, in order of preference for otherwise equal matches. + + """ + + def __init__(self, *associations: 'SecurityAssociation') -> 'None': + self.__associations__ = [] # type: list[SecurityAssociation] + for association in associations: + self.register(association) + + ########################################################################## + # Properties. + ########################################################################## + + @property + def associations(self) -> 'tuple[SecurityAssociation, ...]': + """Registered Security Associations.""" + return tuple(self.__associations__) + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def protocol(cls) -> 'tuple[Literal["ESP"]]': + """Index ID of the protocol this context applies to.""" + return ('ESP',) + + def register(self, association: 'SecurityAssociation') -> 'None': + """Add a Security Association to the context. + + Args: + association: Security Association to add. + + Raises: + ProtocolError: If ``association`` is not a + :class:`SecurityAssociation`. + + """ + if not isinstance(association, SecurityAssociation): + raise ProtocolError(f'not a security association: {association!r}') + self.__associations__.append(association) + + def match(self, spi: 'int', + destination: 'Optional[IPv4Address | IPv6Address]' = None) -> 'Optional[SecurityAssociation]': # pylint: disable=line-too-long + """Find the Security Association that best fits a packet. + + Args: + spi: SPI read from the packet. + destination: Outer destination address, if known. + + Returns: + The best matching SA, or :data:`None` when none applies. + + """ + best = None # type: Optional[SecurityAssociation] + best_score = -1 + for association in self.__associations__: + score = association.matches(spi, destination) + if score > best_score: + best, best_score = association, score + return best + + ########################################################################## + # Data models. + ########################################################################## + + def __repr__(self) -> 'str': + """Representation of the context, free of any key material.""" + return f'' + + +############################################################################## +# Protocol. +############################################################################## + + +class ESP(IPsec[Data_ESP, Schema_ESP], + schema=Schema_ESP, data=Data_ESP): + """This class implements Encapsulating Security Payload.""" + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Encapsulating Security Payload"]': + """Name of corresponding protocol.""" + return 'Encapsulating Security Payload' + + @property + def length(self) -> 'int': + """Length of the ESP header, payload, trailer and ICV. + + Note: + Unlike most protocols, this is *not* just the fixed header: + :rfc:`4303` puts the trailer and the ICV at the end of the + packet, and the next layer is recovered from inside the + ciphertext rather than from the bytes that follow. Every byte + ESP owns is therefore counted here. + + """ + return self._info.length + + ########################################################################## + # Methods. + ########################################################################## + + def read(self, length: 'Optional[int]' = None, *, version: 'Literal[4, 6]' = 4, # pylint: disable=arguments-differ,unused-argument + extension: 'bool' = False, **kwargs: 'Any') -> 'Data_ESP': + """Read Encapsulating Security Payload. + + Structure of ESP header [:rfc:`4303`]: + + .. code-block:: text + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---- + | Security Parameters Index (SPI) | ^Int. + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Cov- + | Sequence Number | |ered + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | ---- + | Payload Data* (variable) | | ^ + ~ ~ | | + | | |Conf. + + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Cov- + | | Padding (0-255 bytes) | |ered* + +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | + | | Pad Length | Next Header | v v + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ------ + | Integrity Check Value-ICV (variable) | + ~ ~ + | | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + Args: + length: Length of packet data. + version: IP protocol version. + extension: If the protocol is used as an IPv6 extension header. + Unlike the other extension headers, ESP terminates the + header chain -- everything after it is encrypted -- so it + decodes its own next layer either way. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Notes: + The outer destination address, used to disambiguate Security + Associations that share an SPI, is taken from the ``packet`` + information the enclosing IP layer passes down. It is not + available for every encapsulation, and an SA that names a + destination still matches when it cannot be confirmed -- see + :meth:`SecurityAssociation.matches`. + + """ + schema = self.__header__ + + data = schema.payload + spi, seq = schema.spi, schema.seq + total = 8 + len(data) + + packet = kwargs.get('packet') + if packet is None: + packet = {} + context = self._get_context(ESPContext) + association = context.match(spi, packet.get('dst')) if context is not None else None + + if association is None: + return self._make_opaque( + spi, seq, total, data, ESPStatus.NO_SA, + f'no security association for SPI {spi:#010x}', + version=version, packet=packet, warning=False, + ) + + # NOTE: The ICV length comes from the security association, not from + # |cryptography|_, so the split and the truncation check are both + # possible whether or not the algorithms are available. Doing them + # first means a truncated packet is reported as TRUNCATED rather than + # masked as UNSUPPORTED, and that an UNSUPPORTED packet still reports + # the ``icv`` it carries -- which for a combined-mode algorithm is the + # authentication tag a caller may well want to see. + icv_length = association.icv_length + if icv_length > len(data): + return self._make_opaque( + spi, seq, total, data, ESPStatus.TRUNCATED, + f'ESP payload is {len(data)} octets, shorter than the {icv_length}-octet ' + f'ICV the security association declares', + version=version, packet=packet, + ) + body, icv = (data[:len(data) - icv_length], data[len(data) - icv_length:]) \ + if icv_length else (data, b'') + + unavailable = association.unavailable() + if unavailable is not None: + return self._make_opaque( + spi, seq, total, body, ESPStatus.UNSUPPORTED, unavailable, + icv=icv, version=version, packet=packet, + ) + + # Separate integrity algorithm, RFC 4303 s3.4.4.1. A combined mode + # algorithm verifies its own tag as part of decryption instead. + if association.integrity is not Integrity.NONE: + if not hmac.compare_digest(association.compute_icv(spi, seq, body), icv): + return self._make_opaque( + spi, seq, total, body, ESPStatus.AUTH_FAILED, + f'integrity check value does not verify for SPI {spi:#010x} ' + f'sequence {seq}', icv=icv, version=version, packet=packet, + ) + + crypto = load_cryptography() + invalid_tag = crypto[3] if crypto is not None else () # type: Any + try: + plaintext = association.decrypt(spi, seq, body, icv) + except ProtocolError as exc: + return self._make_opaque( + spi, seq, total, body, ESPStatus.DECRYPT_FAILED, str(exc), + icv=icv, version=version, packet=packet, + ) + except invalid_tag: + return self._make_opaque( + spi, seq, total, body, ESPStatus.AUTH_FAILED, + f'authentication tag does not verify for SPI {spi:#010x} sequence {seq}; ' + f'the encryption key is most likely wrong', + icv=icv, version=version, packet=packet, + ) + + trailer = self._read_trailer(plaintext, association, spi) + if isinstance(trailer, str): + return self._make_opaque( + spi, seq, total, body, ESPStatus.DECRYPT_FAILED, trailer, + icv=icv, version=version, packet=packet, + ) + inner, padding, pad_len, next_ = trailer + next_type = Enum_TransType.get(next_) + + esp = Data_ESP( + spi=spi, + seq=seq, + length=total, + payload_data=body, + icv=icv, + status=ESPStatus.DECRYPTED, + error=None, + next=next_type, + pad_len=pad_len, + padding=padding, + plaintext=inner, + ) + return self._decode_next_layer(esp, next_type, len(inner), packet=packet, + version=version, payload=inner) + + def make(self, + spi: 'int' = 0, + seq: 'int' = 0, + next: 'Enum_TransType | StdlibEnum | AenumEnum | str | int' = Enum_TransType.UDP, # pylint: disable=redefined-builtin + next_default: 'Optional[int]' = None, + next_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + next_reversed: 'bool' = False, + encrypt: 'bool' = False, + iv: 'Optional[bytes]' = None, + pad_len: 'Optional[int]' = None, + icv: 'bytes' = b'', + payload: 'bytes | Protocol | Schema' = b'', + **kwargs: 'Any') -> 'Schema_ESP': + """Make (construct) packet data. + + There are two modes, chosen by ``encrypt``: + + * ``encrypt=False`` (the default) writes ``payload`` after the SPI + and sequence number verbatim, followed by ``icv``. This is the + mode used to reproduce a captured packet byte for byte, and is + what :meth:`_make_data` drives -- re-encrypting a packet that was + only ever read would change its bytes. + * ``encrypt=True`` treats ``payload`` as the inner plaintext: it + appends :rfc:`4303` §2.4 padding, the pad length and ``next``, + encrypts the result under the Security Association matching + ``spi``, and appends the resulting ICV. + + Args: + spi: Security Parameters Index. + seq: Sequence number. + next: Next header type, written into the ESP trailer. Only used + when ``encrypt`` is :data:`True`. + next_default: Default value of next header type. + next_namespace: Namespace of next header type. + next_reversed: If the namespace is reversed. + encrypt: Whether to protect ``payload``, as described above. + iv: Explicit IV to use; a random one is generated when omitted. + Only used when ``encrypt`` is :data:`True`. + pad_len: Pad length to use; the smallest value that satisfies + the alignment requirement is chosen when omitted. Only used + when ``encrypt`` is :data:`True`. + icv: Integrity check value. Ignored when ``encrypt`` is + :data:`True`, where it is computed instead. + payload: Payload of current instance. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``encrypt`` is :data:`True` and no Security + Association is available for ``spi``, or if ``pad_len`` does + not satisfy the algorithm's alignment requirement. + + """ + if not encrypt: + # the ICV goes after the payload data, so the two have to be + # concatenated by hand; without one, the payload is handed to the + # schema untouched, so that a Protocol or Schema payload is packed + # by the field rather than here + if icv: + return Schema_ESP(spi=spi, seq=seq, payload=self._payload_bytes(payload) + icv) + return Schema_ESP(spi=spi, seq=seq, payload=payload) + + context = self._get_context(ESPContext) + association = context.match(spi) if context is not None else None + if association is None: + raise ProtocolError(f'no security association for SPI {spi:#010x}; ESP cannot be ' + f'constructed with encrypt=True without one') + + next_value = self._make_index(next, next_default, namespace=next_namespace, + reversed=next_reversed, pack=False) + + plain = self._payload_bytes(payload) + align = max(association.cipher_suite.block_size, 4) + if pad_len is None: + pad_len = -(len(plain) + 2) % align + elif (len(plain) + pad_len + 2) % align: + raise ProtocolError(f'pad length {pad_len} leaves ' + f'{len(plain) + pad_len + 2} octets, which is not a multiple ' + f'of the required {align}-octet alignment') + if not 0 <= pad_len <= 255: + raise ProtocolError(f'invalid pad length: {pad_len}') + + # RFC 4303 s2.4: padding bytes are a monotonically increasing + # sequence starting at 1. + plaintext = plain + bytes(range(1, pad_len + 1)) + bytes([pad_len, next_value]) + body, tag = association.encrypt(spi, seq, plaintext, iv) + if association.integrity is not Integrity.NONE: + tag = association.compute_icv(spi, seq, body) + + return Schema_ESP( + spi=spi, + seq=seq, + payload=body + tag, + ) + + @classmethod + def id(cls) -> 'tuple[Literal["ESP"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('ESP',) + + ########################################################################## + # Data models. + ########################################################################## + + @overload + def __post_init__(self, file: 'IO[bytes] | bytes', length: 'Optional[int]' = ..., *, # pylint: disable=arguments-differ + version: 'Literal[4, 6]' = ..., extension: 'bool' = ..., + **kwargs: 'Any') -> 'None': ... + @overload + def __post_init__(self, **kwargs: 'Any') -> 'None': ... # pylint: disable=arguments-differ + + def __post_init__(self, file: 'Optional[IO[bytes] | bytes]' = None, length: 'Optional[int]' = None, *, # pylint: disable=arguments-differ + version: 'Literal[4, 6]' = 4, extension: 'bool' = False, + **kwargs: 'Any') -> 'None': + """Post initialisation hook. + + Args: + file: Source packet stream. + length: Length of packet data. + version: IP protocol version. + extension: If the protocol is used as an IPv6 extension header. + **kwargs: Arbitrary keyword arguments. + + See Also: + For construction argument, please refer to :meth:`self.make `. + + """ + #: bool: If the protocol is used as an IPv6 extension header. + self._extf = extension + + # call super __post_init__ + super().__post_init__(file, length, version=version, extension=extension, **kwargs) # type: ignore[arg-type] + + def __length_hint__(self) -> 'Literal[8]': + """Return an estimated length for the object.""" + return 8 + + @classmethod + def __index__(cls) -> 'Enum_TransType': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Returns: + Numeral registry index of the protocol in `IANA`_. + + .. _IANA: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml + + """ + return Enum_TransType.ESP # type: ignore[return-value] + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_ESP') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + The payload is reproduced verbatim rather than re-encrypted, so that + reconstruction is byte exact and does not need the keys. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'spi': data.spi, + 'seq': data.seq, + 'payload': data.payload_data + data.icv, + } + + @staticmethod + def _payload_bytes(payload: 'bytes | Protocol | Schema') -> 'bytes': + """Render ``payload`` as :obj:`bytes`. + + Args: + payload: Payload as supplied to :meth:`make`. + + Returns: + Packed payload. + + Raises: + ProtocolUnbound: If ``payload`` is of an unsupported type. This + mirrors :meth:`Schema.pack `, + which rejects the same set. + + """ + from pcapkit.protocols.protocol import \ + ProtocolBase # pylint: disable=import-outside-toplevel + + if isinstance(payload, bytes): + return payload + if isinstance(payload, Schema): + return payload.pack() + if isinstance(payload, ProtocolBase): + return bytes(payload) + raise ProtocolUnbound(f'unsupported type {type(payload)}') + + @staticmethod + def _read_trailer(plaintext: 'bytes', association: 'SecurityAssociation', + spi: 'int') -> 'tuple[bytes, bytes, int, int] | str': + """Split the ESP trailer off the decrypted plaintext. + + Args: + plaintext: Decrypted payload data, i.e. the inner payload + followed by the ESP trailer. + association: Security Association the packet was decrypted with. + spi: Security Parameters Index, for the diagnostic message. + + Returns: + A 4-tuple of the inner payload, the padding, the pad length and + the next header, or a :obj:`str` explaining why the trailer is + not self consistent. + + """ + if len(plaintext) < 2: + return (f'decrypted ESP payload for SPI {spi:#010x} is {len(plaintext)} octets, ' + f'too short to hold a pad length and next header') + + pad_len, next_ = plaintext[-2], plaintext[-1] + if pad_len + 2 > len(plaintext): + return (f'decrypted ESP payload for SPI {spi:#010x} declares {pad_len} octets of ' + f'padding but holds only {len(plaintext) - 2}; the encryption key is most ' + f'likely wrong') + + padding = plaintext[len(plaintext) - 2 - pad_len:len(plaintext) - 2] + inner = plaintext[:len(plaintext) - 2 - pad_len] + + if padding != bytes(range(1, pad_len + 1)): + message = (f'padding of the decrypted ESP payload for SPI {spi:#010x} does not ' + f'follow the monotonically increasing sequence of RFC 4303 s2.4') + if association.strict and not association.authenticated: + # Nothing authenticated this packet, so the padding pattern + # is the only wrong-key signal available. + return f'{message}; the encryption key is most likely wrong' + warn(message, ProtocolWarning) + + return inner, padding, pad_len, next_ + + def _make_opaque(self, spi: 'int', seq: 'int', total: 'int', payload_data: 'bytes', + status: 'ESPStatus', error: 'str', *, icv: 'bytes' = b'', + version: 'Literal[4, 6]' = 4, + packet: 'Optional[dict[str, Any]]' = None, + warning: 'bool' = True) -> 'Data_ESP': + """Report an ESP packet whose payload was not decrypted. + + The payload is surfaced as :class:`~pcapkit.protocols.misc.raw.Raw` + -- which is what a next header of :data:`None` resolves to -- so that + :attr:`self.payload ` + and the protocol chain behave as they do for any other protocol, and + the trailer fields are left :data:`None` rather than guessed at. + + Args: + spi: Security Parameters Index. + seq: Sequence number. + total: Total length of the ESP portion of the packet. + payload_data: Payload data, excluding the ICV. + status: Why the payload was not decrypted. + error: Human readable form of ``status``. + icv: Integrity check value, when its length is known. + version: IP protocol version. + packet: Packet information from the enclosing layer. + warning: Whether to warn; a capture taken without keys is the + expected case and does not warrant one. + + Returns: + Parsed packet data. + + """ + if warning: + warn(error, ProtocolWarning) + + esp = Data_ESP( + spi=spi, + seq=seq, + length=total, + payload_data=payload_data, + icv=icv, + status=status, + error=error, + next=None, + pad_len=None, + padding=None, + plaintext=None, + ) + return self._decode_next_layer(esp, None, len(payload_data) + len(icv), + packet=packet or None, version=version, + payload=payload_data + icv) diff --git a/pcapkit/protocols/internet/internet.py b/pcapkit/protocols/internet/internet.py index 9666a6f94a..d698b7dc04 100644 --- a/pcapkit/protocols/internet/internet.py +++ b/pcapkit/protocols/internet/internet.py @@ -57,6 +57,8 @@ class Internet(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstra - :class:`pcapkit.protocols.internet.ipv6_route.IPv6_Route` * - :attr:`~pcapkit.const.reg.transtype.TransType.IPv6_Frag` - :class:`pcapkit.protocols.internet.ipv6_frag.IPv6_Frag` + * - :attr:`~pcapkit.const.reg.transtype.TransType.ESP` + - :class:`pcapkit.protocols.internet.esp.ESP` * - :attr:`~pcapkit.const.reg.transtype.TransType.AH` - :class:`pcapkit.protocols.internet.ah.AH` * - :attr:`~pcapkit.const.reg.transtype.TransType.IPv6_NoNxt` @@ -94,6 +96,7 @@ class Internet(Protocol[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstra Enum_TransType.IPv6: ModuleDescriptor('pcapkit.protocols.internet.ipv6', 'IPv6'), Enum_TransType.IPv6_Route: ModuleDescriptor('pcapkit.protocols.internet.ipv6_route', 'IPv6_Route'), Enum_TransType.IPv6_Frag: ModuleDescriptor('pcapkit.protocols.internet.ipv6_frag', 'IPv6_Frag'), + Enum_TransType.ESP: ModuleDescriptor('pcapkit.protocols.internet.esp', 'ESP'), Enum_TransType.AH: ModuleDescriptor('pcapkit.protocols.internet.ah', 'AH'), Enum_TransType.IPv6_NoNxt: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), Enum_TransType.IPv6_Opts: ModuleDescriptor('pcapkit.protocols.internet.ipv6_opts', 'IPv6_Opts'), @@ -248,5 +251,6 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # self.__proto__[proto] = protocol # update mapping upon import next_ = protocol(file_, length, version=version, extension=extension, # type: ignore[abstract] - alias=proto, packet=packet, layer=self._exlayer, protocol=self._exproto) + alias=proto, packet=packet, layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) return next_ diff --git a/pcapkit/protocols/internet/ipsec.py b/pcapkit/protocols/internet/ipsec.py index 1a831038c9..746fc7cda3 100644 --- a/pcapkit/protocols/internet/ipsec.py +++ b/pcapkit/protocols/internet/ipsec.py @@ -9,11 +9,9 @@ only, which is a base class for Internet Protocol Security (IPsec) protocol family [*]_, eg. :class:`~pcapkit.protocols.internet.ah.AH` and -:class:`~pcapkit.protocols.internet.esp.ESP` [*]_. +:class:`~pcapkit.protocols.internet.esp.ESP`. .. [*] https://en.wikipedia.org/wiki/IPsec -.. [*] :class:`~pcapkit.protocols.internet.esp.ESP` - class is currently **NOT** implemented. """ from typing import TYPE_CHECKING, Generic diff --git a/pcapkit/protocols/internet/ipv4.py b/pcapkit/protocols/internet/ipv4.py index 2ddcb4ecd1..ea2cae7e99 100644 --- a/pcapkit/protocols/internet/ipv4.py +++ b/pcapkit/protocols/internet/ipv4.py @@ -217,7 +217,8 @@ def dst(self) -> 'IPv4Address': # Methods. ########################################################################## - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_IPv4': # pylint: disable=unused-argument + def read(self, length: 'Optional[int]' = None, *, # pylint: disable=unused-argument,arguments-differ + __packet__: 'Optional[dict[str, Any]]' = None, **kwargs: 'Any') -> 'Data_IPv4': """Read Internet Protocol version 4 (IPv4). Structure of IPv4 header [:rfc:`791`]: @@ -242,6 +243,7 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_IPv4': Args: length: Length of packet data. + __packet__: Optional packet data. **kwargs: Arbitrary keyword arguments. Returns: @@ -285,7 +287,16 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_IPv4': ('options', self._read_ipv4_options(_optl)), ]) - return self._decode_next_layer(ipv4, ipv4.protocol, ipv4.len - ipv4.hdr_len) + # update packet info + if __packet__ is None: + __packet__ = {} + __packet__.update({ + 'src': ipv4.src, + 'dst': ipv4.dst, + }) + + return self._decode_next_layer(ipv4, ipv4.protocol, ipv4.len - ipv4.hdr_len, + packet=__packet__) def make(self, tos_pre: 'Enum_ToSPrecedence | StdlibEnum | AenumEnum | int | str' = Enum_ToSPrecedence.Routine, diff --git a/pcapkit/protocols/internet/ipv6.py b/pcapkit/protocols/internet/ipv6.py index cbc2881db2..1d0c94556d 100644 --- a/pcapkit/protocols/internet/ipv6.py +++ b/pcapkit/protocols/internet/ipv6.py @@ -411,5 +411,6 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # self.__proto__[proto] = protocol # update mapping upon import next_ = protocol(file_, length, version=version, extension=extension, # type: ignore[abstract] - alias=proto, packet=packet, layer=self._exlayer, protocol=self._exproto) + alias=proto, packet=packet, layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) return next_ diff --git a/pcapkit/protocols/protocol.py b/pcapkit/protocols/protocol.py index ef76946c37..47deb1b146 100644 --- a/pcapkit/protocols/protocol.py +++ b/pcapkit/protocols/protocol.py @@ -28,6 +28,7 @@ import aenum import chardet +from pcapkit.corekit.context import ContextRegistry from pcapkit.corekit.module import ModuleDescriptor from pcapkit.corekit.protochain import ProtoChain from pcapkit.protocols import data as data_module @@ -50,10 +51,13 @@ from aenum import IntEnum as AenumEnum from typing_extensions import Literal, Self + from pcapkit.corekit.context import ProtocolContext + __all__ = ['ProtocolBase'] _PT = TypeVar('_PT', bound='Data') _ST = TypeVar('_ST', bound='Schema') +_CTX = TypeVar('_CTX', bound='ProtocolContext') # readable characters' order list readable = [ord(char) for char in filter(lambda char: not char.isspace(), string.printable)] @@ -119,6 +123,14 @@ class ProtocolBase(Generic[_PT, _ST], metaclass=ProtocolMeta): lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), ) + #: Caller supplied parsing context, c.f. :mod:`pcapkit.corekit.context`. + #: :meth:`self.__init__ ` replaces this with a real + #: :class:`~pcapkit.corekit.context.ContextRegistry`; the class level + #: :data:`None` is what an instance built without going through + #: ``__init__`` -- e.g. ``object.__new__(SomeProtocol)`` -- sees, so that + #: reading it is always safe. + _exctx: 'Optional[ContextRegistry]' = None + ########################################################################## # Properties. ########################################################################## @@ -197,6 +209,19 @@ def schema(self) -> '_ST': """Schema data of the protocol.""" return self.__header__ + # caller supplied parsing context + @property + def context(self) -> 'ContextRegistry': + """Caller supplied parsing context. + + See Also: + :mod:`pcapkit.corekit.context` for what this channel is for, and + :meth:`self._get_context ` for how a + protocol implementation reaches its own entry. + + """ + return ContextRegistry.make(self._exctx) + ########################################################################## # Methods. ########################################################################## @@ -503,6 +528,12 @@ def __init__(self, file: 'Optional[IO[bytes] | bytes]' = None, length: 'Optional (:attr:`self._exlayer `). _protocol (Union[str, Protocol, Type[Protocol]]): Parse packet until ``_protocol`` (:attr:`self._exproto `). + __context__ (Union[ContextRegistry, ProtocolContext, Mapping[str, ProtocolContext], Iterable[ProtocolContext]]): + Caller supplied parsing context (:attr:`self._exctx `), + c.f. :mod:`pcapkit.corekit.context`. It is consumed here rather + than being forwarded to :meth:`self.read `, and is + propagated to nested layers by + :meth:`self._import_next_layer `. **kwargs: Arbitrary keyword arguments. """ @@ -514,6 +545,14 @@ def __init__(self, file: 'Optional[IO[bytes] | bytes]' = None, length: 'Optional self._exlayer = kwargs.pop('_layer', None) # type: Optional[str] #: str: Parse packet until such protocol. self._exproto = kwargs.pop('_protocol', None) # type: Optional[str | ProtocolBase | Type[ProtocolBase]] + #: pcapkit.corekit.context.ContextRegistry: Caller supplied parsing context. + # NOTE: Every nested layer normalises the context it was handed, so an + # already-normalised registry is adopted as-is: ``make()`` copies, and + # paying for a dict copy per protocol in a capture buys nothing when the + # contexts are shared regardless. + __context__ = kwargs.pop('__context__', None) + self._exctx = (__context__ if isinstance(__context__, ContextRegistry) + else ContextRegistry.make(__context__)) # type: ContextRegistry #: bool: If terminate parsing next layer of protocol. self._sigterm = self._check_term_threshold() @@ -763,6 +802,31 @@ def __hash__(self) -> 'int': # Utilities. ########################################################################## + def _get_context(self, cls: 'Optional[Type[_CTX]]' = None) -> 'Optional[_CTX]': + """Get the caller supplied context for this protocol, if any. + + The lookup is keyed on :meth:`self.id `, so a + protocol finds its own context without knowing how the caller spelled + the registry. + + Args: + cls: Expected context class; when given, a context registered + under this protocol's name but of another type is ignored + rather than returned for the implementation to trip over. + + Returns: + The matching context, or :data:`None` when the caller supplied + none. + + See Also: + :mod:`pcapkit.corekit.context` + + """ + registry = self._exctx + if registry is None: + return None + return registry.match(self.id(), cls) + def _get_payload(self) -> 'bytes': """Get payload from :attr:`self.__header__ `. @@ -1154,7 +1218,8 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, self.__proto__[proto] = protocol # update mapping upon import next_ = protocol(file_, length, alias=proto, packet=packet, - layer=self._exlayer, protocol=self._exproto) # type: ignore[abstract] + layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) # type: ignore[abstract] return next_ def _check_term_threshold(self) -> bool: diff --git a/pcapkit/protocols/schema/internet/__init__.py b/pcapkit/protocols/schema/internet/__init__.py index bae4d80066..61e7aa14b7 100644 --- a/pcapkit/protocols/schema/internet/__init__.py +++ b/pcapkit/protocols/schema/internet/__init__.py @@ -4,6 +4,9 @@ # Authentication Header from pcapkit.protocols.schema.internet.ah import AH +# Encapsulating Security Payload +from pcapkit.protocols.schema.internet.esp import ESP + # Host Identity Protocol from pcapkit.protocols.schema.internet.hip import HIP from pcapkit.protocols.schema.internet.hip import AckDataParameter as HIP_AckDataParameter @@ -215,6 +218,9 @@ # Authentication Header 'AH', + # Encapsulating Security Payload + 'ESP', + # Host Identity Protocol 'HIP', 'HIP_LocatorData', 'HIP_Locator', 'HIP_ECDSACurveHostIdentity', 'HIP_ECDSALowCurveHostIdentity', diff --git a/pcapkit/protocols/schema/internet/esp.py b/pcapkit/protocols/schema/internet/esp.py new file mode 100644 index 0000000000..c513bd1056 --- /dev/null +++ b/pcapkit/protocols/schema/internet/esp.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for encapsulating security payload""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.fields.misc import PayloadField +from pcapkit.corekit.fields.numbers import UInt32Field +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['ESP'] + +if TYPE_CHECKING: + from pcapkit.protocols.protocol import ProtocolBase as Protocol + + +@schema_final +class ESP(Schema): + """Header schema for ESP packet. + + Notes: + Only the two fixed fields of :rfc:`4303` -- ``SPI`` and ``Sequence + Number`` -- can be described declaratively. Everything after them + (the payload data, including any cryptographic synchronisation such + as an IV, the ESP trailer and the optional Integrity Check Value) is + of a length that is a property of the Security Association rather + than of the packet, so it is captured verbatim as :attr:`payload` + and split by :meth:`ESP.read `. + + This also means :attr:`payload` is **not** the next layer's data: + the next layer lives inside the ciphertext, and is handed to + :meth:`Protocol._decode_next_layer ` + explicitly once decrypted. + + """ + + #: Security parameters index. + spi: 'int' = UInt32Field() + #: Sequence number field. + seq: 'int' = UInt32Field() + #: Payload data, ESP trailer and integrity check value, verbatim. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, spi: 'int', seq: 'int', + payload: 'bytes | Protocol | Schema') -> 'None': ... diff --git a/pcapkit/vendor/__init__.py b/pcapkit/vendor/__init__.py index 798044af14..2356d165a0 100644 --- a/pcapkit/vendor/__init__.py +++ b/pcapkit/vendor/__init__.py @@ -40,6 +40,7 @@ # per protocol from pcapkit.vendor.arp import * +from pcapkit.vendor.esp import * from pcapkit.vendor.ftp import * from pcapkit.vendor.hip import * from pcapkit.vendor.http import * @@ -58,6 +59,8 @@ 'EtherType', 'LinkType', 'TransType', 'AppType', # ARP 'ARP_Hardware', 'ARP_Operation', + # ESP + 'ESP_Cipher', 'ESP_Integrity', # FTP 'FTP_Command', 'FTP_ReturnCode', # HIP diff --git a/pcapkit/vendor/esp/__init__.py b/pcapkit/vendor/esp/__init__.py new file mode 100644 index 0000000000..155c949a20 --- /dev/null +++ b/pcapkit/vendor/esp/__init__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +""":class:`~pcapkit.protocols.internet.esp.ESP` Vendor Crawlers +================================================================= + +.. module:: pcapkit.vendor.esp + +This module contains all vendor crawlers of +:class:`~pcapkit.protocols.internet.esp.ESP` implementations. Available +enumerations include: + +.. list-table:: + + * - :class:`ESP_Cipher ` + - Encryption Algorithm Transform IDs [*]_ + * - :class:`ESP_Integrity ` + - Integrity Algorithm Transform IDs [*]_ + +ESP has no algorithm registry of its own: an SA's algorithms are negotiated by +IKEv2, so both enumerations are the corresponding IKEv2 *transform ID* +sub-registries. They live here rather than under an ``ikev2`` package because +:class:`~pcapkit.protocols.internet.esp.ESP` is the only thing in +:mod:`pcapkit` that consumes them. + +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-5 +.. [*] https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-7 + +""" + +from pcapkit.vendor.esp.cipher import Cipher as ESP_Cipher +from pcapkit.vendor.esp.integrity import Integrity as ESP_Integrity + +__all__ = ['ESP_Cipher', 'ESP_Integrity'] diff --git a/pcapkit/vendor/esp/cipher.py b/pcapkit/vendor/esp/cipher.py new file mode 100644 index 0000000000..aed7aa7d18 --- /dev/null +++ b/pcapkit/vendor/esp/cipher.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""Transform Type 1 - Encryption Algorithm Transform IDs +========================================================= + +.. module:: pcapkit.vendor.esp.cipher + +This module contains the vendor crawler for **Transform Type 1 - Encryption Algorithm Transform IDs**, +which is automatically generating :class:`pcapkit.const.esp.cipher.Cipher`. + +""" + +import csv +import re +import sys + +from pcapkit.vendor.default import Vendor + +__all__ = ['Cipher'] + +#: Reference cells that name no document. +UNKNOWN = ('', '-', 'UNSPECIFIED') + + +class Cipher(Vendor): + """Transform Type 1 - Encryption Algorithm Transform IDs""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0 <= value <= 65535' + #: Link to registry. + LINK = 'https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters-5.csv' + + @staticmethod + def rfcs(text: 'str') -> 'str': + """Render the citations of a registry cell as reStructuredText. + + Args: + text: Raw ``Status`` or ``ESP Reference`` cell. IANA wraps long + cells, so the text may hold embedded newlines. + + Returns: + The cell with its ``[RFCxxxx]`` citations turned into + :rst:role:`rfc` roles, or an empty string when the cell names no + document at all. + + """ + text = re.sub(r'\]\s+\[', '][', re.sub(r'\s+', ' ', text)).strip() + if text.upper() in UNKNOWN: + return '' + return re.sub(r'\[RFC(\d+)\]', + lambda match: f'[:rfc:`{match.group(1)}`]', text).replace('_', ' ') + + def process(self, data: 'list[str]') -> 'tuple[list[str], list[str]]': + """Process CSV data. + + The registry's columns are ``Number``, ``Name``, ``Status``, + ``ESP Reference`` and ``IKEv2 Reference``. Only the ESP reference is + carried into the enumeration -- this registry is pulled for ESP, and + the IKEv2 column repeats it for most transforms. + + Args: + data: CSV data. + + Returns: + Enumeration fields and missing fields. + + """ + reader = csv.reader(data) + next(reader) # header + + enum = [] # type: list[str] + miss = [] # type: list[str] + for item in reader: + name = item[1] + status = self.rfcs(item[2]) + refs = self.rfcs(item[3]) + + desc = self.wrap_comment(' '.join(filter(None, ( + name, refs, f'({status})' if status else '', + )))) + + try: + code, _ = item[0], int(item[0]) + except ValueError: + start, stop = item[0].split('-') + + miss.append(f'if {start} <= value <= {stop}:') + miss.append(f' #: {desc}') + miss.append(f" return extend_enum(cls, '{self.safe_name(name)}_%d' % value, value)") + continue + + renm = self.rename(name, code) + enum.append(f'#: {desc}\n {renm} = {code}') + + # The registry spells every transform with an ``ENCR_`` prefix, + # whereas ESP -- and RFC 8221, which says which of them to + # implement -- names them without it. Both spellings are worth + # having, so the prefix-stripped one is emitted as an alias. A + # few registry names start with a digit once the prefix is gone + # (``ENCR_3DES``), which is no identifier; those get none. + alias = renm[5:] if renm.startswith('ENCR_') else '' + if alias.isidentifier(): + enum.append(f'#: Alias of :attr:`{self.NAME}.{renm}`.\n {alias} = {code}') + return enum, miss + + +if __name__ == '__main__': + sys.exit(Cipher()) # type: ignore[arg-type] diff --git a/pcapkit/vendor/esp/integrity.py b/pcapkit/vendor/esp/integrity.py new file mode 100644 index 0000000000..15bd6e52cd --- /dev/null +++ b/pcapkit/vendor/esp/integrity.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +"""Transform Type 3 - Integrity Algorithm Transform IDs +======================================================== + +.. module:: pcapkit.vendor.esp.integrity + +This module contains the vendor crawler for **Transform Type 3 - Integrity Algorithm Transform IDs**, +which is automatically generating :class:`pcapkit.const.esp.integrity.Integrity`. + +""" + +import csv +import re +import sys + +from pcapkit.vendor.default import Vendor + +__all__ = ['Integrity'] + +#: Reference cells that name no document. +UNKNOWN = ('', '-', 'UNSPECIFIED') + + +class Integrity(Vendor): + """Transform Type 3 - Integrity Algorithm Transform IDs""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0 <= value <= 65535' + #: Link to registry. + LINK = 'https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters-7.csv' + + @staticmethod + def rfcs(text: 'str') -> 'str': + """Render the citations of a registry cell as reStructuredText. + + Args: + text: Raw ``Status`` or ``Reference`` cell. IANA wraps long cells, + so the text may hold embedded newlines. + + Returns: + The cell with its ``[RFCxxxx]`` citations turned into + :rst:role:`rfc` roles, or an empty string when the cell names no + document at all. + + """ + text = re.sub(r'\]\s+\[', '][', re.sub(r'\s+', ' ', text)).strip() + if text.upper() in UNKNOWN: + return '' + return re.sub(r'\[RFC(\d+)\]', + lambda match: f'[:rfc:`{match.group(1)}`]', text).replace('_', ' ') + + def process(self, data: 'list[str]') -> 'tuple[list[str], list[str]]': + """Process CSV data. + + The registry's columns are ``Number``, ``Name``, ``Status`` and + ``Reference``. + + Args: + data: CSV data. + + Returns: + Enumeration fields and missing fields. + + """ + reader = csv.reader(data) + next(reader) # header + + enum = [] # type: list[str] + miss = [] # type: list[str] + for item in reader: + name = item[1] + status = self.rfcs(item[2]) + refs = self.rfcs(item[3]) + + desc = self.wrap_comment(' '.join(filter(None, ( + name, refs, f'({status})' if status else '', + )))) + + try: + code, _ = item[0], int(item[0]) + except ValueError: + start, stop = item[0].split('-') + + miss.append(f'if {start} <= value <= {stop}:') + miss.append(f' #: {desc}') + miss.append(f" return extend_enum(cls, '{self.safe_name(name)}_%d' % value, value)") + continue + + renm = self.rename(name, code) + enum.append(f'#: {desc}\n {renm} = {code}') + + # The registry spells every transform with an ``AUTH_`` prefix, + # whereas ESP -- and RFC 8221, which says which of them to + # implement -- names them without it. Both spellings are worth + # having, so the prefix-stripped one is emitted as an alias. + # ``NONE`` carries no prefix and so gets none. + alias = renm[5:] if renm.startswith('AUTH_') else '' + if alias.isidentifier(): + enum.append(f'#: Alias of :attr:`{self.NAME}.{renm}`.\n {alias} = {code}') + return enum, miss + + +if __name__ == '__main__': + sys.exit(Integrity()) # type: ignore[arg-type] diff --git a/pyproject.toml b/pyproject.toml index d5e17c71db..9f2f6d9510 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,8 @@ pcapkit-vendor = "pcapkit.vendor.__main__:main" [project.optional-dependencies] # for CLI display cli = [ "emoji" ] +# for ESP payload decryption, c.f. pcapkit.protocols.internet.esp +crypto = [ "cryptography>=3.4" ] # for normal users DPKT = [ "dpkt" ] Scapy = [ "scapy" ] @@ -90,6 +92,7 @@ PyShark = [ "pyshark" ] vendor = [ "requests[socks]", "beautifulsoup4[html5lib]" ] all = [ "emoji", + "cryptography>=3.4", "dpkt", "scapy", "pyshark", "requests[socks]", "beautifulsoup4[html5lib]", ] diff --git a/tests/foundation/engines/test_runtime_engines.py b/tests/foundation/engines/test_runtime_engines.py index 37489321a5..76602ce1da 100644 --- a/tests/foundation/engines/test_runtime_engines.py +++ b/tests/foundation/engines/test_runtime_engines.py @@ -29,6 +29,11 @@ def __call__(self, *args, **kwargs): def make_extractor(**overrides): + # imported lazily: setUp purges ``pcapkit`` from sys.modules, so the class + # has to be fetched from the freshly imported package rather than bound at + # module import time + from pcapkit.corekit.context import ContextRegistry + sink = OutputSink() reasm = types.SimpleNamespace(ipv4=mock.Mock(), ipv6=mock.Mock(), tcp=mock.Mock()) trace = types.SimpleNamespace(tcp=mock.Mock()) @@ -56,6 +61,7 @@ def make_extractor(**overrides): '_frnum': 0, '_exlyr': 'none', '_exptl': 'null', + '_exctx': ContextRegistry(), '_vfunc': mock.Mock(), 'magic_number': b'\xa1\xb2\xc3\xd4', } diff --git a/tests/protocols/internet/test_esp_unit.py b/tests/protocols/internet/test_esp_unit.py new file mode 100644 index 0000000000..a308917282 --- /dev/null +++ b/tests/protocols/internet/test_esp_unit.py @@ -0,0 +1,1020 @@ +"""Unit tests for :mod:`pcapkit.protocols.internet.esp`. + +Cryptographic behaviour is pinned with *published* test vectors rather than +with round trips alone, so that a change to the nonce, associated data or +trailer handling cannot pass unnoticed: + +* **AES-CBC** -- :rfc:`3602` §4, cases 5 and 7. These are complete ESP + packets (transport mode and tunnel mode respectively) with encryption only, + and give the key, IV, plaintext and ciphertext. +* **AES-GCM** -- ``draft-mcgrew-gcm-test-01`` §4, "Test Cases for the use of + Galois/Counter Mode (GCM) and Galois Message Authentication Code (GMAC) in + IPsec ESP", which is the packet level companion to :rfc:`4106`. Two + non-ESN cases are used, one with a 128-bit and one with a 256-bit key. +* **HMAC-SHA-256-128 integrity** -- no published ESP packet vector exists, so + the expected ICV is recomputed in the test with :mod:`hmac` over the + coverage :rfc:`4303` §2.8 specifies, independently of + :meth:`SecurityAssociation.compute_icv + `, and the + resulting bytes are additionally pinned. + +""" +from __future__ import annotations + +import hashlib +import hmac +import importlib.util +import os +import struct +import tempfile +import unittest +from unittest import mock + +from tests._support import close_extractor, purge_modules + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) +HAS_CRYPTO = importlib.util.find_spec('cryptography') is not None + + +def hx(value: str) -> bytes: + """Decode a whitespace-formatted hex string, as the RFCs print them.""" + return bytes.fromhex(''.join(value.split())) + + +# RFC 3602 s4 case 5 -- transport mode ESP, AES-CBC-128, no integrity. +CASE5_KEY = hx('90d382b4 10eeba7a d938c46c ec1a82bf') +CASE5_IP = hx('4500007c 08f20000 4032f9a5 c0a87b03 c0a87b64') +CASE5_ESP = hx(''' + 00004321 00000001 + e96e8c08 ab465763 fd098d45 dd3ff893 + f663c25d 325c18c6 a9453e19 4e120849 a4870b66 cc6b9965 330013b4 898dc856 + a4699e52 3a55db08 0b59ec3a 8e4b7e52 775b07d1 db34ed9c 538ab50c 551b874a + a269add0 47ad2d59 13ac19b7 cfbad4a6''') +CASE5_PLAINTEXT = hx(''' + 08000ebd a70a0000 8e9c083d b95b0700 08090a0b 0c0d0e0f 10111213 14151617 + 18191a1b 1c1d1e1f 20212223 24252627 28292a2b 2c2d2e2f 30313233 34353637''') +CASE5_PADDING = hx('01020304 05060708 090a0b0c 0d0e') + +# RFC 3602 s4 case 7 -- tunnel mode ESP, AES-CBC-128, no integrity. +CASE7_KEY = hx('01234567 89abcdef 01234567 89abcdef') +CASE7_IP = hx('4500008c 09050000 4032f91e c0a87b03 c0a87bc8') +CASE7_ESP = hx(''' + 00008765 00000002 + f4e76524 4f6407ad f13dc138 0f673f37 + 773b5241 a4c44922 5e4f3ce5 ed611b0c 237ca96c f74a9301 3c1b0ea1 a0cf70f8 + e4ecaec7 8ac53aad 7a0f022b 859243c6 47752e94 a859352b 8a4d4d2d ecd136e5 + c177f132 ad3fbfb2 201ac990 4c74ee0a 109e0ca1 e4dfe9d5 a100b842 f1c22f0d''') +CASE7_PLAINTEXT = hx(''' + 45000054 09040000 4001f988 c0a87b03 c0a87bc8 08009f76 a90a0100 b49c083d + 02a20400 08090a0b 0c0d0e0f 10111213 14151617 18191a1b 1c1d1e1f 20212223 + 24252627 28292a2b 2c2d2e2f 30313233 34353637''') + +# draft-mcgrew-gcm-test-01 s4 -- AES-GCM-ESP, 128-bit key, 4-octet salt +# ``cafebabe`` taken from the published nonce ``cafebabefacedbaddecaf888``. +GCM128_KEYMAT = hx('feffe992 8665731c 6d6a8f94 67308308 cafebabe') +GCM128_PACKET = hx(''' + 0000a5f8 0000000a facedbad decaf888 + deb22cd9 b07c72c1 6e3a65be eb8df304 + a5a5897d 33ae530f 1ba76d5d 114d2a5c + 3de81827 c10e9a4f 51330d0e ec416642 + cfbb85a5 b47e48a4 ec3b9ba9 5d918bd1 + 83b70d3a a8bc6ee4 c309e9d8 5a41ad4a''') +GCM128_DECRYPTED = hx(''' + 4500003e 698f0000 80114dcc c0a80102 + c0a80101 0a980035 002a2343 b2d00100 + 00010000 00000000 03736970 09637962 + 65726369 74790264 6b000001 00010001''') + +# draft-mcgrew-gcm-test-01 s4 -- AES-GCM-ESP, 256-bit key, salt ``11223344``. +GCM256_KEYMAT = hx(''' + abbccdde f0011223 34455667 78899aab + abbccdde f0011223 34455667 78899aab + 11223344''') +GCM256_PACKET = hx(''' + 4a2cbfe3 00000002 01020304 05060708 + ff425c9b 724599df 7a3bcd51 0194e00d + 6a78107f 1b0b1cbf 06efae9d 65a5d763 + 748a6379 85771d34 7f054565 9f14e99d + ef842d8e b335f4ee cfdbf831 824b4c49 + 15956c96''') +GCM256_DECRYPTED = hx(''' + 45000030 69a64000 80062690 c0a80102 + 9389155e 0a9e008b 2dc57ee0 00000000 + 70024000 20bf0000 020405b4 01010402 + 01020201''') + +#: Inner IPv4/TCP datagram used for the ``make(encrypt=True)`` round trip. +INNER_TCP = hx(''' + 45000028 00010000 4006f97e c0a80101 c0a80102 + 00140050 00000000 00000000 50022000 00000000''') + + +def make_pcap(frame: bytes) -> str: + """Write a one frame Ethernet PCAP file to a temporary directory.""" + path = os.path.join(tempfile.mkdtemp(prefix='pcapkit-esp-'), 'esp.pcap') + with open(path, 'wb') as file: + # little endian, v2.4, LINKTYPE_ETHERNET + file.write(struct.pack(' None: + purge_modules(['pcapkit']) + + def test_cipher_registry(self) -> None: + from pcapkit.const.esp.cipher import Cipher as Const_Cipher + from pcapkit.protocols.internet.esp import CIPHER_SUITES, Cipher, CipherSuite + from pcapkit.utilities.exceptions import ProtocolError + + # the enumeration is the generated IANA registry, re-exported + self.assertIs(Cipher, Const_Cipher) + + # IKEv2 transform type 1 identifiers + self.assertEqual(Cipher.ENCR_NULL, 11) + self.assertEqual(Cipher.ENCR_AES_CBC, 12) + self.assertEqual(Cipher.ENCR_AES_GCM_8, 18) + self.assertEqual(Cipher.ENCR_AES_GCM_12, 19) + self.assertEqual(Cipher.ENCR_AES_GCM_16, 20) + + # ... and their prefix-stripped aliases, which is how ESP names them + self.assertIs(Cipher.NULL, Cipher.ENCR_NULL) + self.assertIs(Cipher.AES_CBC, Cipher.ENCR_AES_CBC) + self.assertIs(Cipher.AES_GCM_8, Cipher.ENCR_AES_GCM_8) + self.assertIs(Cipher.AES_GCM_12, Cipher.ENCR_AES_GCM_12) + self.assertIs(Cipher.AES_GCM_16, Cipher.ENCR_AES_GCM_16) + + for spelling in ('AES-CBC', 'aes_cbc', 'ENCR_AES_CBC', 12, Cipher.AES_CBC): + with self.subTest(spelling=spelling): + self.assertIs(CipherSuite.get(spelling).cipher, Cipher.ENCR_AES_CBC) + + # registration is not support: the registry is much larger than the + # set of algorithms pcapkit can apply, and the suite table is the + # authority on the latter + self.assertGreater(len(Cipher), 30) + self.assertEqual(set(CIPHER_SUITES), { + Cipher.ENCR_NULL, Cipher.ENCR_AES_CBC, Cipher.ENCR_AES_GCM_8, + Cipher.ENCR_AES_GCM_12, Cipher.ENCR_AES_GCM_16, + }) + + null = CIPHER_SUITES[Cipher.ENCR_NULL] + cbc = CIPHER_SUITES[Cipher.ENCR_AES_CBC] + gcm16 = CIPHER_SUITES[Cipher.ENCR_AES_GCM_16] + self.assertFalse(cbc.is_aead) + self.assertTrue(gcm16.is_aead) + self.assertEqual(cbc.iv_length, 16) + self.assertEqual(gcm16.iv_length, 8) + self.assertEqual(null.iv_length, 0) + self.assertEqual(cbc.block_size, 16) + self.assertEqual(gcm16.block_size, 1) + self.assertEqual(CIPHER_SUITES[Cipher.ENCR_AES_GCM_8].icv_length, 8) + self.assertEqual(CIPHER_SUITES[Cipher.ENCR_AES_GCM_12].icv_length, 12) + self.assertEqual(gcm16.icv_length, 16) + self.assertEqual(cbc.icv_length, 0) + self.assertEqual(gcm16.salt_length, 4) + self.assertEqual(cbc.salt_length, 0) + self.assertEqual(null.key_sizes, (0,)) + self.assertEqual(cbc.key_sizes, (16, 24, 32)) + self.assertFalse(null.requires_cryptography) + self.assertTrue(cbc.requires_cryptography) + + # deliberately unimplemented, but registered: the enumeration holds + # them -- and its own permissive ``get`` answers only "did IANA + # register this" -- while the suite lookup refuses them + self.assertEqual(Cipher.ENCR_3DES, 3) + self.assertEqual(Cipher.ENCR_CHACHA20_POLY1305, 28) + self.assertIs(Cipher.get('ENCR_3DES'), Cipher.ENCR_3DES) + with self.assertRaises(ProtocolError): + CipherSuite.get('3DES') + with self.assertRaises(ProtocolError): + CipherSuite.get('CHACHA20_POLY1305') + with self.assertRaises(ProtocolError): + CipherSuite.get(3) + # and a name that is in no registry at all + with self.assertRaises(ProtocolError): + CipherSuite.get('ROT13') + + def test_integrity_registry(self) -> None: + from pcapkit.const.esp.integrity import Integrity as Const_Integrity + from pcapkit.protocols.internet.esp import INTEGRITY_SUITES, Integrity, IntegritySuite + from pcapkit.utilities.exceptions import ProtocolError + + self.assertIs(Integrity, Const_Integrity) + + # IKEv2 transform type 3 identifiers; the registry spells 0 ``NONE`` + # rather than ``AUTH_NONE``, so that member carries no alias + self.assertEqual(Integrity.NONE, 0) + self.assertEqual(Integrity.AUTH_HMAC_SHA1_96, 2) + self.assertEqual(Integrity.AUTH_HMAC_SHA2_256_128, 12) + self.assertEqual(Integrity.AUTH_HMAC_SHA2_384_192, 13) + self.assertEqual(Integrity.AUTH_HMAC_SHA2_512_256, 14) + self.assertIs(Integrity.HMAC_SHA1_96, Integrity.AUTH_HMAC_SHA1_96) + self.assertIs(Integrity.HMAC_SHA2_256_128, Integrity.AUTH_HMAC_SHA2_256_128) + self.assertIs(Integrity.HMAC_SHA2_384_192, Integrity.AUTH_HMAC_SHA2_384_192) + self.assertIs(Integrity.HMAC_SHA2_512_256, Integrity.AUTH_HMAC_SHA2_512_256) + self.assertNotIn('AUTH_NONE', Integrity.__members__) + + for spelling in ('HMAC-SHA2-256-128', 'AUTH_HMAC_SHA2_256_128', + 'hmac_sha_256_128', 12): + with self.subTest(spelling=spelling): + self.assertIs(IntegritySuite.get(spelling).integrity, + Integrity.AUTH_HMAC_SHA2_256_128) + + self.assertEqual(set(INTEGRITY_SUITES), { + Integrity.NONE, Integrity.AUTH_HMAC_SHA1_96, Integrity.AUTH_HMAC_SHA2_256_128, + Integrity.AUTH_HMAC_SHA2_384_192, Integrity.AUTH_HMAC_SHA2_512_256, + }) + + # RFC 4868 truncation lengths and key sizes + self.assertEqual(INTEGRITY_SUITES[Integrity.AUTH_HMAC_SHA1_96].icv_length, 12) + self.assertEqual(INTEGRITY_SUITES[Integrity.AUTH_HMAC_SHA2_256_128].icv_length, 16) + self.assertEqual(INTEGRITY_SUITES[Integrity.AUTH_HMAC_SHA2_384_192].icv_length, 24) + self.assertEqual(INTEGRITY_SUITES[Integrity.AUTH_HMAC_SHA2_512_256].icv_length, 32) + self.assertEqual(INTEGRITY_SUITES[Integrity.AUTH_HMAC_SHA1_96].key_size, 20) + self.assertEqual(INTEGRITY_SUITES[Integrity.AUTH_HMAC_SHA2_512_256].key_size, 64) + self.assertIsNone(INTEGRITY_SUITES[Integrity.NONE].digest) + self.assertEqual(INTEGRITY_SUITES[Integrity.AUTH_HMAC_SHA2_256_128].digest, 'sha256') + + # registered but unimplemented, as above + self.assertEqual(Integrity.AUTH_HMAC_MD5_96, 1) + self.assertEqual(Integrity.AUTH_AES_XCBC_96, 5) + with self.assertRaises(ProtocolError): + IntegritySuite.get('HMAC_MD5_96') + with self.assertRaises(ProtocolError): + IntegritySuite.get('AES_XCBC_96') + with self.assertRaises(ProtocolError): + IntegritySuite.get('HMAC_SHA3_256') + + def test_security_association_validation(self) -> None: + from pcapkit.protocols.internet.esp import Cipher, Integrity, SecurityAssociation + from pcapkit.utilities.exceptions import ProtocolError + + # RFC 4106 s8.1: the last four octets of the keying material are the salt + sa = SecurityAssociation(spi=1, encryption=Cipher.AES_GCM_16, + encryption_key=GCM128_KEYMAT) + self.assertEqual(sa.encryption_key, GCM128_KEYMAT[:16]) + self.assertEqual(sa.salt, hx('cafebabe')) + self.assertEqual(sa.icv_length, 16) + self.assertTrue(sa.authenticated) + + # an explicitly supplied salt is accepted too + split = SecurityAssociation(spi=1, encryption='aes-gcm-16', + encryption_key=GCM128_KEYMAT[:16], + salt=hx('cafebabe')) + self.assertEqual(split.encryption_key, sa.encryption_key) + self.assertEqual(split.salt, sa.salt) + + # AEAD provides its own integrity; combining is a configuration error + with self.assertRaises(ProtocolError): + SecurityAssociation(spi=1, encryption=Cipher.AES_GCM_16, + encryption_key=GCM128_KEYMAT, + integrity=Integrity.HMAC_SHA2_256_128, + integrity_key=bytes(32)) + + # AES key lengths are a hard requirement + with self.assertRaises(ProtocolError): + SecurityAssociation(spi=1, encryption=Cipher.AES_CBC, encryption_key=bytes(15)) + with self.assertRaises(ProtocolError): + SecurityAssociation(spi=1, encryption=Cipher.AES_GCM_16, encryption_key=bytes(4)) + with self.assertRaises(ProtocolError): + SecurityAssociation(spi=-1) + with self.assertRaises(ProtocolError): + SecurityAssociation(spi=1, icv_length=-1) + + # RFC 8221 s6 notes implementations that truncate SHA-256 to 96 bits + truncated = SecurityAssociation(spi=1, integrity=Integrity.HMAC_SHA2_256_128, + integrity_key=bytes(32), icv_length=12) + self.assertEqual(truncated.icv_length, 12) + + # an unprotected SA is legitimate: it says where the trailer is + null = SecurityAssociation(spi=1) + self.assertEqual(null.icv_length, 0) + self.assertFalse(null.authenticated) + self.assertIsNone(null.unavailable()) + + def test_security_association_repr_holds_no_key_material(self) -> None: + from pcapkit.protocols.internet.esp import (Cipher, ESPContext, Integrity, + SecurityAssociation) + + akey = bytes(range(32)) + sa = SecurityAssociation(spi=0x1234, encryption=Cipher.AES_CBC, + encryption_key=CASE5_KEY, + integrity=Integrity.HMAC_SHA2_256_128, integrity_key=akey, + destination='192.168.123.100') + text = repr(sa) + self.assertNotIn(CASE5_KEY.hex(), text.lower()) + self.assertNotIn(akey.hex(), text.lower()) + self.assertIn('0x00001234', text) + self.assertIn('AES_CBC', text) + self.assertIn('192.168.123.100', text) + + self.assertNotIn(CASE5_KEY.hex(), repr(ESPContext(sa)).lower()) + + def test_context_registry_normalisation(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import ESPContext, SecurityAssociation + from pcapkit.utilities.exceptions import RegistryError + + context = ESPContext(SecurityAssociation(spi=1)) + self.assertEqual(ESPContext.protocol(), ('ESP',)) + + for value in (context, [context], {'ESP': context}, ContextRegistry(context)): + with self.subTest(value=type(value).__name__): + registry = ContextRegistry.make(value) + self.assertIs(registry['esp'], context) + self.assertIn('ESP', registry) + self.assertEqual(len(registry), 1) + self.assertTrue(registry) + self.assertEqual(list(registry), ['ESP']) + self.assertIs(registry.match(('ESP',)), context) + self.assertIs(registry.match(('ESP',), ESPContext), context) + self.assertIsNone(registry.match(('AH',))) + + empty = ContextRegistry.make(None) + self.assertFalse(empty) + self.assertNotIn('ESP', empty) + self.assertNotIn(50, empty) + + # a context of the wrong type is ignored rather than handed over + self.assertIsNone(ContextRegistry.make(context).match(('ESP',), SecurityAssociation)) # type: ignore[arg-type] + + with self.assertRaises(RegistryError): + ContextRegistry(context).register(context) + with self.assertRaises(RegistryError): + ContextRegistry.make(object()) + with self.assertRaises(RegistryError): + ContextRegistry().register('not-a-context') # type: ignore[arg-type] + + def test_association_matching_prefers_the_most_specific(self) -> None: + import ipaddress + + from pcapkit.protocols.internet.esp import ESPContext, SecurityAssociation + from pcapkit.utilities.exceptions import ProtocolError + + dst = ipaddress.ip_address('192.168.123.100') + wildcard = SecurityAssociation() + pinned = SecurityAssociation(spi=0x4321) + addressed = SecurityAssociation(spi=0x4321, destination='192.168.123.100') + + context = ESPContext(wildcard, pinned, addressed) + self.assertEqual(context.associations, (wildcard, pinned, addressed)) + self.assertIs(context.match(0x4321, dst), addressed) + self.assertIs(context.match(0x9999, dst), wildcard) + # the destination cannot be confirmed, so the pinned SA wins on SPI + self.assertIs(context.match(0x4321), pinned) + # a destination that is known and different rules the SA out + other = ipaddress.ip_address('10.0.0.1') + self.assertIs(context.match(0x4321, other), pinned) + self.assertEqual(addressed.matches(0x4321, other), -1) + self.assertEqual(addressed.matches(0x1111, dst), -1) + + with self.assertRaises(ProtocolError): + ESPContext().register('not-an-sa') # type: ignore[arg-type] + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class ESPProtocolTests(unittest.TestCase): + """Parsing, decryption and construction.""" + + def setUp(self) -> None: + purge_modules(['pcapkit']) + + ########################################################################## + # Registration and identity. + ########################################################################## + + def test_identity_and_registration(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.module import ModuleDescriptor + from pcapkit.protocols.internet.esp import ESP + from pcapkit.protocols.internet.internet import Internet + from pcapkit.protocols.internet.ipsec import IPsec + + self.assertEqual(ESP.id(), ('ESP',)) + self.assertEqual(IPsec.id(), ('AH', 'ESP')) + self.assertEqual(ESP.__index__(), TransType.ESP) + self.assertEqual(ESP.__index__(), 50) + self.assertEqual(object.__new__(ESP).__length_hint__(), 8) + self.assertEqual(object.__new__(ESP).name, 'Encapsulating Security Payload') + self.assertTrue(issubclass(ESP, IPsec)) + + # IP protocol 50 must reach ESP rather than falling through to Raw + registered = Internet.__proto__[TransType.ESP] + if isinstance(registered, ModuleDescriptor): + registered = registered.klass + self.assertIs(registered, ESP) + + def test_module_is_importable_and_exported(self) -> None: + import pcapkit + from pcapkit.protocols.internet.esp import ESP + + self.assertIn('ESP', pcapkit.__all__) + self.assertIs(pcapkit.ESP, ESP) + self.assertIs(pcapkit.protocols.__proto__['ESP'], ESP) + + ########################################################################## + # Without SA context. + ########################################################################## + + def test_no_sa_reports_opaque_payload(self) -> None: + from pcapkit.protocols.internet.esp import ESP, ESPStatus + + esp = ESP(CASE5_ESP, len(CASE5_ESP)) + info = esp.info + + self.assertEqual(info.spi, 0x4321) + self.assertEqual(info.seq, 1) + self.assertEqual(info.length, len(CASE5_ESP)) + self.assertEqual(esp.length, len(CASE5_ESP)) + self.assertIs(info.status, ESPStatus.NO_SA) + self.assertIn('no security association', info.error) + + # the remainder is opaque; nothing about the trailer is guessed at + self.assertEqual(info.payload_data, CASE5_ESP[8:]) + self.assertEqual(info.icv, b'') + self.assertIsNone(info.next) + self.assertIsNone(info.pad_len) + self.assertIsNone(info.padding) + self.assertIsNone(info.plaintext) + + # and the payload is still reachable, as Raw + self.assertEqual(str(esp.protochain), 'ESP:Raw') + self.assertEqual(esp.payload.data, CASE5_ESP[8:]) + + def test_no_sa_never_raises_on_a_short_packet(self) -> None: + from pcapkit.protocols.internet.esp import ESP, ESPStatus + + payload = struct.pack('!II', 0xdeadbeef, 42) + esp = ESP(payload, len(payload)) + self.assertIs(esp.info.status, ESPStatus.NO_SA) + self.assertEqual(esp.info.spi, 0xdeadbeef) + self.assertEqual(esp.info.seq, 42) + self.assertEqual(esp.info.payload_data, b'') + + def test_null_cipher_recovers_the_trailer_without_cryptography(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import ESP, ESPContext, ESPStatus, SecurityAssociation + + # ESP-NULL: padding 01 02, pad length 2, next header 6 (TCP) + packet = struct.pack('!II', 7, 3) + b'plaintext-payload' + bytes([1, 2, 2, 6]) + registry = ContextRegistry.make(ESPContext(SecurityAssociation(spi=7))) + + esp = ESP(packet, len(packet), __context__=registry) + info = esp.info + self.assertIs(info.status, ESPStatus.DECRYPTED) + self.assertEqual(info.plaintext, b'plaintext-payload') + self.assertEqual(info.pad_len, 2) + self.assertEqual(info.padding, bytes([1, 2])) + self.assertEqual(info.next, TransType.TCP) + self.assertEqual(info.icv, b'') + + ########################################################################## + # AES-CBC, RFC 3602 known answer vectors. + ########################################################################## + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_aes_cbc_transport_mode_rfc3602_case5(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + sa = SecurityAssociation(spi=0x4321, encryption=Cipher.AES_CBC, + encryption_key=CASE5_KEY) + esp = ESP(CASE5_ESP, len(CASE5_ESP), + __context__=ContextRegistry.make(ESPContext(sa))) + info = esp.info + + self.assertIs(info.status, ESPStatus.DECRYPTED) + self.assertIsNone(info.error) + self.assertEqual(info.spi, 0x4321) + self.assertEqual(info.seq, 1) + self.assertEqual(info.plaintext, CASE5_PLAINTEXT) + self.assertEqual(info.pad_len, 0x0e) + self.assertEqual(info.padding, CASE5_PADDING) + self.assertEqual(info.next, TransType.ICMP) + self.assertEqual(info.icv, b'') + # the IV stays on the wire as part of the payload data + self.assertEqual(info.payload_data, CASE5_ESP[8:]) + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_aes_cbc_tunnel_mode_rfc3602_case7_decodes_inner_ip(self) -> None: + import ipaddress + + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (Cipher, ESPContext, ESPStatus, + SecurityAssociation) + from pcapkit.protocols.internet.ipv4 import IPv4 + + sa = SecurityAssociation(spi=0x8765, encryption=Cipher.AES_CBC, + encryption_key=CASE7_KEY) + packet = CASE7_IP + CASE7_ESP + ipv4 = IPv4(packet, len(packet), __context__=ContextRegistry.make(ESPContext(sa))) + + esp = ipv4.payload + self.assertIs(esp.info.status, ESPStatus.DECRYPTED) + self.assertEqual(esp.info.plaintext, CASE7_PLAINTEXT) + self.assertEqual(esp.info.pad_len, 0x0a) + self.assertEqual(esp.info.next, TransType.IPv4) + + # the encapsulated datagram is dispatched to the next layer + self.assertEqual(str(ipv4.protochain), 'IPv4:ESP:IPv4:ICMP') + inner = esp.payload + self.assertEqual(inner.info.src, ipaddress.ip_address('192.168.123.3')) + self.assertEqual(inner.info.dst, ipaddress.ip_address('192.168.123.200')) + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_destination_keyed_association(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (Cipher, ESPContext, ESPStatus, + SecurityAssociation) + from pcapkit.protocols.internet.ipv4 import IPv4 + + packet = CASE7_IP + CASE7_ESP + + def parse(destination: str) -> 'ESPStatus': + sa = SecurityAssociation(spi=0x8765, encryption=Cipher.AES_CBC, + encryption_key=CASE7_KEY, destination=destination) + ipv4 = IPv4(packet, len(packet), + __context__=ContextRegistry.make(ESPContext(sa))) + return ipv4.payload.info.status + + # the outer IPv4 destination reaches ESP, so an SA pinned to the right + # address matches and one pinned elsewhere does not + self.assertIs(parse('192.168.123.200'), ESPStatus.DECRYPTED) + self.assertIs(parse('10.0.0.1'), ESPStatus.NO_SA) + + ########################################################################## + # AES-GCM, draft-mcgrew-gcm-test-01 known answer vectors. + ########################################################################## + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_aes_gcm_128_mcgrew_vector(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + sa = SecurityAssociation(spi=0x0000a5f8, encryption=Cipher.AES_GCM_16, + encryption_key=GCM128_KEYMAT) + esp = ESP(GCM128_PACKET, len(GCM128_PACKET), + __context__=ContextRegistry.make(ESPContext(sa))) + info = esp.info + + self.assertIs(info.status, ESPStatus.DECRYPTED) + self.assertEqual(info.spi, 0x0000a5f8) + self.assertEqual(info.seq, 10) + # pad length 0, next header 1 -- the last two octets of the plaintext + self.assertEqual(info.pad_len, 0) + self.assertEqual(info.padding, b'') + self.assertEqual(info.next, TransType.ICMP) + self.assertEqual(info.plaintext, GCM128_DECRYPTED[:-2]) + # the AEAD tag is the ICV, and it is not part of the payload data + self.assertEqual(info.icv, GCM128_PACKET[-16:]) + self.assertEqual(info.payload_data, GCM128_PACKET[8:-16]) + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_aes_gcm_256_mcgrew_vector(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import ESP, ESPContext, ESPStatus, SecurityAssociation + + sa = SecurityAssociation(spi=0x4a2cbfe3, encryption='aes-gcm-16', + encryption_key=GCM256_KEYMAT) + self.assertEqual(sa.encryption_key, GCM256_KEYMAT[:32]) + self.assertEqual(sa.salt, hx('11223344')) + + esp = ESP(GCM256_PACKET, len(GCM256_PACKET), + __context__=ContextRegistry.make(ESPContext(sa))) + info = esp.info + + self.assertIs(info.status, ESPStatus.DECRYPTED) + self.assertEqual(info.seq, 2) + self.assertEqual(info.plaintext, GCM256_DECRYPTED[:-4]) + self.assertEqual(info.pad_len, 2) + self.assertEqual(info.padding, bytes([1, 2])) + self.assertEqual(info.icv, GCM256_PACKET[-16:]) + + ########################################################################## + # Failure modes. + ########################################################################## + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_wrong_encryption_key_fails_cleanly(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet import esp as esp_module + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + sa = SecurityAssociation(spi=0x4321, encryption=Cipher.AES_CBC, + encryption_key=bytes(16)) + registry = ContextRegistry.make(ESPContext(sa)) + + with mock.patch.object(esp_module, 'warn') as warned: + esp = ESP(CASE5_ESP, len(CASE5_ESP), __context__=registry) + info = esp.info + + # no garbage plaintext is produced, and the failure is announced + self.assertIs(info.status, ESPStatus.DECRYPT_FAILED) + self.assertIsNone(info.plaintext) + self.assertIsNone(info.next) + self.assertIsNone(info.pad_len) + self.assertIn('most likely wrong', info.error) + warned.assert_called() + self.assertEqual(str(esp.protochain), 'ESP:Raw') + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_wrong_aead_key_fails_the_tag_check(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + sa = SecurityAssociation(spi=0x0000a5f8, encryption=Cipher.AES_GCM_16, + encryption_key=bytes(16) + hx('cafebabe')) + esp = ESP(GCM128_PACKET, len(GCM128_PACKET), + __context__=ContextRegistry.make(ESPContext(sa))) + + self.assertIs(esp.info.status, ESPStatus.AUTH_FAILED) + self.assertIsNone(esp.info.plaintext) + self.assertIn('authentication tag does not verify', esp.info.error) + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_padding_pattern_is_only_decisive_without_integrity(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet import esp as esp_module + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + # build a packet whose padding is zeros rather than the RFC 4303 s2.4 + # monotonically increasing sequence; 16 payload + 14 padding + 2 + # trailer octets is a whole number of AES blocks + key = CASE5_KEY + builder = SecurityAssociation(spi=0x55, encryption=Cipher.AES_CBC, encryption_key=key) + plaintext = b'sixteen-byte-pay' + bytes(14) + bytes([14, 6]) + self.assertEqual(len(plaintext) % 16, 0) + body, _ = builder.encrypt(0x55, 1, plaintext, iv=bytes(range(16))) + packet = struct.pack('!II', 0x55, 1) + body + + # strict, unauthenticated -> the mismatch is treated as a wrong key + strict = ContextRegistry.make(ESPContext( + SecurityAssociation(spi=0x55, encryption=Cipher.AES_CBC, encryption_key=key))) + self.assertIs(ESP(packet, len(packet), __context__=strict).info.status, + ESPStatus.DECRYPT_FAILED) + + # strict=False -> warn, but accept the payload + lenient = ContextRegistry.make(ESPContext( + SecurityAssociation(spi=0x55, encryption=Cipher.AES_CBC, encryption_key=key, + strict=False))) + with mock.patch.object(esp_module, 'warn') as warned: + esp = ESP(packet, len(packet), __context__=lenient) + self.assertIs(esp.info.status, ESPStatus.DECRYPTED) + self.assertEqual(esp.info.plaintext, b'sixteen-byte-pay') + self.assertEqual(esp.info.padding, bytes(14)) + warned.assert_called() + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_truncated_icv_is_reported(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + sa = SecurityAssociation(spi=0x0000a5f8, encryption=Cipher.AES_GCM_16, + encryption_key=GCM128_KEYMAT) + registry = ContextRegistry.make(ESPContext(sa)) + + # only the SPI, the sequence number and 8 octets remain, which cannot + # hold the 16-octet ICV the SA declares + short = GCM128_PACKET[:16] + esp = ESP(short, len(short), __context__=registry) + self.assertIs(esp.info.status, ESPStatus.TRUNCATED) + self.assertIn('shorter than the 16-octet ICV', esp.info.error) + self.assertIsNone(esp.info.plaintext) + self.assertEqual(esp.info.payload_data, short[8:]) + + # a payload that is not a whole number of AES blocks fails too + cbc = ContextRegistry.make(ESPContext( + SecurityAssociation(spi=0x4321, encryption=Cipher.AES_CBC, + encryption_key=CASE5_KEY))) + clipped = CASE5_ESP[:-3] + esp = ESP(clipped, len(clipped), __context__=cbc) + self.assertIs(esp.info.status, ESPStatus.DECRYPT_FAILED) + self.assertIsNone(esp.info.plaintext) + + def test_degrades_without_cryptography(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet import esp as esp_module + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + with mock.patch.object(esp_module, 'load_cryptography', return_value=None): + with mock.patch.object(esp_module, 'warn') as warned: + sa = SecurityAssociation(spi=0x4321, encryption=Cipher.AES_CBC, + encryption_key=CASE5_KEY) + # the SA warns as soon as it is built, so the problem is visible + # before a single packet has been parsed + warned.assert_called_once() + self.assertIn('cryptography', warned.call_args.args[0]) + self.assertIsNotNone(sa.unavailable()) + + esp = ESP(CASE5_ESP, len(CASE5_ESP), + __context__=ContextRegistry.make(ESPContext(sa))) + + info = esp.info + self.assertIs(info.status, ESPStatus.UNSUPPORTED) + self.assertIn('cryptography', info.error) + self.assertIsNone(info.plaintext) + # ... and the packet still parses, down the opaque payload path + self.assertEqual(info.spi, 0x4321) + self.assertEqual(info.payload_data, CASE5_ESP[8:]) + self.assertEqual(str(esp.protochain), 'ESP:Raw') + + def test_icv_is_split_off_even_when_cryptography_is_missing(self) -> None: + """The ICV length comes from the SA, not from |cryptography|_. + + So an UNSUPPORTED packet still reports the ICV it carries -- which for + a combined-mode algorithm is the authentication tag -- and a packet too + short to hold the declared ICV is reported as TRUNCATED rather than + being masked as UNSUPPORTED. + + """ + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet import esp as esp_module + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + body = bytes(range(32)) + tag = bytes(range(0xa0, 0xb0)) # 16-octet ICV + packet = struct.pack('!II', 0x1234, 7) + body + tag + + with mock.patch.object(esp_module, 'load_cryptography', return_value=None): + with mock.patch.object(esp_module, 'warn'): + sa = SecurityAssociation(spi=0x1234, encryption=Cipher.AES_GCM_16, + encryption_key=bytes(20)) + registry = ContextRegistry.make(ESPContext(sa)) + + info = ESP(packet, len(packet), __context__=registry).info + self.assertIs(info.status, ESPStatus.UNSUPPORTED) + self.assertEqual(info.icv, tag) + self.assertEqual(info.payload_data, body) + self.assertIsNone(info.plaintext) + + # one octet short of the declared 16-octet ICV + short = struct.pack('!II', 0x1234, 8) + tag[:15] + short_info = ESP(short, len(short), __context__=registry).info + self.assertIs(short_info.status, ESPStatus.TRUNCATED) + + ########################################################################## + # Integrity. + ########################################################################## + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_hmac_sha256_integrity_round_trip_and_mismatch(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, Integrity, + SecurityAssociation) + + akey = bytes(range(32)) + iv = bytes(range(16)) + + def association(ckey: bytes = CASE5_KEY, ikey: bytes = akey) -> 'SecurityAssociation': + return SecurityAssociation(spi=0x1234, encryption=Cipher.AES_CBC, + encryption_key=ckey, + integrity=Integrity.HMAC_SHA2_256_128, + integrity_key=ikey) + + registry = ContextRegistry.make(ESPContext(association())) + built = ESP(spi=0x1234, seq=7, next=TransType.IPv4, encrypt=True, iv=iv, + payload=INNER_TCP, __context__=registry) + wire = bytes(built) + + # RFC 4303 s2.8: the ICV covers the SPI, the sequence number, the + # payload data (IV included) and the explicit trailer -- everything + # transmitted bar the ICV itself. Recomputed here with the standard + # library rather than with pcapkit's own helper. + expected = hmac.new(akey, wire[:-16], hashlib.sha256).digest()[:16] + self.assertEqual(wire[-16:], expected) + # pinned, so a change in the covered range cannot pass silently + self.assertEqual(wire[-16:].hex(), '29003487091e61aebabd417e11ee3242') + + esp = ESP(wire, len(wire), __context__=registry) + info = esp.info + self.assertIs(info.status, ESPStatus.DECRYPTED) + self.assertEqual(info.plaintext, INNER_TCP) + self.assertEqual(info.next, TransType.IPv4) + self.assertEqual(info.icv, expected) + # an ESP tunnelled TCP segment decodes as TCP + self.assertEqual(str(esp.protochain), 'ESP:IPv4:TCP') + self.assertEqual(esp.payload.payload.info.dstport, 80) + + # a wrong integrity key is caught before decryption is attempted + bad_auth = ContextRegistry.make(ESPContext(association(ikey=bytes(32)))) + failed = ESP(wire, len(wire), __context__=bad_auth) + self.assertIs(failed.info.status, ESPStatus.AUTH_FAILED) + self.assertIsNone(failed.info.plaintext) + self.assertIn('integrity check value does not verify', failed.info.error) + + # a right integrity key with a wrong cipher key gets past the ICV and + # is caught by the trailer instead + bad_cipher = ContextRegistry.make(ESPContext(association(ckey=bytes(16)))) + failed = ESP(wire, len(wire), __context__=bad_cipher) + self.assertIs(failed.info.status, ESPStatus.DECRYPT_FAILED) + self.assertIsNone(failed.info.plaintext) + + # a corrupted ICV is a mismatch + tampered = wire[:-1] + bytes([wire[-1] ^ 0xFF]) + failed = ESP(tampered, len(tampered), __context__=registry) + self.assertIs(failed.info.status, ESPStatus.AUTH_FAILED) + + def test_compute_icv_requires_an_integrity_algorithm(self) -> None: + from pcapkit.protocols.internet.esp import Integrity, SecurityAssociation + from pcapkit.utilities.exceptions import ProtocolError + + sa = SecurityAssociation(spi=1, integrity=Integrity.HMAC_SHA1_96, + integrity_key=bytes(20)) + icv = sa.compute_icv(1, 1, b'body') + self.assertEqual(len(icv), 12) + self.assertEqual(icv, hmac.new(bytes(20), struct.pack('!II', 1, 1) + b'body', + hashlib.sha1).digest()[:12]) + + with self.assertRaises(ProtocolError): + SecurityAssociation(spi=1).compute_icv(1, 1, b'body') + + ########################################################################## + # Construction. + ########################################################################## + + def test_make_verbatim_round_trip(self) -> None: + from pcapkit.protocols.internet.esp import ESP, ESPStatus + + built = ESP(spi=9, seq=3, payload=b'opaque-ciphertext', icv=b'ICV!') + wire = bytes(built) + self.assertEqual(wire, struct.pack('!II', 9, 3) + b'opaque-ciphertextICV!') + + schema = object.__new__(ESP).make(spi=9, seq=3, payload=b'body') + self.assertEqual(schema.spi, 9) + self.assertEqual(schema.seq, 3) + self.assertEqual(schema.payload, b'body') + + parsed = ESP(wire, len(wire)) + self.assertIs(parsed.info.status, ESPStatus.NO_SA) + self.assertEqual(parsed.info.payload_data, b'opaque-ciphertextICV!') + + # from_data reproduces the packet byte for byte, without needing keys + values = ESP._make_data(parsed.info) + self.assertEqual(values, {'spi': 9, 'seq': 3, + 'payload': b'opaque-ciphertextICV!'}) + self.assertEqual(bytes(ESP.from_data(parsed.info)), wire) + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_make_encrypt_pads_to_the_block_size(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + from pcapkit.utilities.exceptions import ProtocolError, ProtocolUnbound + + sa = SecurityAssociation(spi=0x77, encryption=Cipher.AES_CBC, + encryption_key=CASE5_KEY) + registry = ContextRegistry.make(ESPContext(sa)) + + for size in range(0, 20): + with self.subTest(size=size): + built = ESP(spi=0x77, seq=1, next=TransType.UDP, encrypt=True, + iv=bytes(16), payload=bytes(size), __context__=registry) + wire = bytes(built) + # 8 octets of header, 16 of IV, then whole AES blocks + self.assertEqual((len(wire) - 24) % 16, 0) + + esp = ESP(wire, len(wire), __context__=registry) + self.assertIs(esp.info.status, ESPStatus.DECRYPTED) + self.assertEqual(esp.info.plaintext, bytes(size)) + self.assertEqual(esp.info.next, TransType.UDP) + # RFC 4303 s2.4: padding is 1, 2, 3, ... + self.assertEqual(esp.info.padding, + bytes(range(1, esp.info.pad_len + 1))) + + # an explicit pad length that breaks the alignment is refused + with self.assertRaises(ProtocolError): + ESP(spi=0x77, seq=1, encrypt=True, pad_len=1, payload=bytes(8), + __context__=registry) + # ... as is asking to encrypt with no SA at all + with self.assertRaises(ProtocolError): + ESP(spi=0x99, seq=1, encrypt=True, payload=b'x') + # ... and a payload that is neither bytes, a schema nor a protocol + with self.assertRaises(ProtocolUnbound): + object.__new__(ESP)._payload_bytes(object()) + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_make_encrypt_aead_round_trip(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + for cipher, icv_length in ((Cipher.AES_GCM_8, 8), + (Cipher.AES_GCM_12, 12), + (Cipher.AES_GCM_16, 16)): + with self.subTest(cipher=cipher.name): + sa = SecurityAssociation(spi=0x88, encryption=cipher, + encryption_key=GCM128_KEYMAT) + self.assertEqual(sa.icv_length, icv_length) + registry = ContextRegistry.make(ESPContext(sa)) + + built = ESP(spi=0x88, seq=5, next=TransType.IPv4, encrypt=True, + iv=bytes(range(8)), payload=INNER_TCP, __context__=registry) + wire = bytes(built) + esp = ESP(wire, len(wire), __context__=registry) + + self.assertIs(esp.info.status, ESPStatus.DECRYPTED) + self.assertEqual(esp.info.plaintext, INNER_TCP) + self.assertEqual(len(esp.info.icv), icv_length) + self.assertEqual(str(esp.protochain), 'ESP:IPv4:TCP') + + ########################################################################## + # Extraction, end to end. + ########################################################################## + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_extract_end_to_end_with_context(self) -> None: + import pcapkit + from pcapkit.protocols.internet.esp import (Cipher, ESPContext, ESPStatus, + SecurityAssociation) + + ethernet = hx('001122334455 66778899aabb 0800') + path = make_pcap(ethernet + CASE7_IP + CASE7_ESP) + output = os.path.join(os.path.dirname(path), 'out.txt') + + sa = SecurityAssociation(spi=0x8765, encryption=Cipher.AES_CBC, + encryption_key=CASE7_KEY, + destination='192.168.123.200') + extraction = pcapkit.extract(fin=path, fout=output, format='tree', + context=ESPContext(sa)) + try: + frame = extraction.frame[0] + self.assertEqual(str(frame.protochain), 'Ethernet:IPv4:ESP:IPv4:ICMP') + esp = frame.info.ethernet.ipv4.esp + self.assertIs(esp.status, ESPStatus.DECRYPTED) + self.assertEqual(esp.plaintext, CASE7_PLAINTEXT) + finally: + close_extractor(extraction) + + # the dump carries the packet, but never the key material + with open(output, 'r', encoding='utf-8') as file: + text = file.read() + self.assertIn('ESP', text) + self.assertNotIn(CASE7_KEY.hex(), text.lower().replace(' ', '')) + self.assertNotIn('SecurityAssociation', text) + self.assertNotIn('encryption_key', text) + + # without the context, the same capture still extracts + extraction = pcapkit.extract(fin=path, nofile=True) + try: + frame = extraction.frame[0] + self.assertEqual(str(frame.protochain), 'Ethernet:IPv4:ESP:Raw') + self.assertIs(frame.info.ethernet.ipv4.esp.status, ESPStatus.NO_SA) + finally: + close_extractor(extraction) + + @unittest.skipUnless(HAS_CRYPTO, 'cryptography not installed') + def test_ipv6_extension_header_position(self) -> None: + from pcapkit.const.reg.transtype import TransType + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.internet.esp import (ESP, Cipher, ESPContext, ESPStatus, + SecurityAssociation) + from pcapkit.protocols.internet.ipv6 import IPv6 + + sa = SecurityAssociation(spi=0x66, encryption=Cipher.AES_CBC, + encryption_key=CASE5_KEY) + registry = ContextRegistry.make(ESPContext(sa)) + + built = ESP(spi=0x66, seq=1, next=TransType.TCP, encrypt=True, iv=bytes(16), + payload=INNER_TCP[20:], __context__=registry) + body = bytes(built) + + # IPv6 header with next header 50 (ESP) + header = (bytes([0x60, 0, 0, 0]) + struct.pack('!H', len(body)) + bytes([50, 64]) + + bytes.fromhex('20010db8' + '00' * 12) + + bytes.fromhex('20010db8' + '00' * 11 + '01')) + packet = header + body + + ipv6 = IPv6(packet, len(packet), __context__=registry) + esp = ipv6.info.esp + self.assertIs(esp.status, ESPStatus.DECRYPTED) + self.assertEqual(esp.next, TransType.TCP) + self.assertEqual(esp.plaintext, INNER_TCP[20:]) + # ESP terminates the IPv6 header chain, so it is recorded as an + # extension header and carries the inner layer itself + self.assertIn(str(ESP.__index__()), [str(key) for key in ipv6.extension_headers]) + self.assertIn('ESP', str(ipv6.protochain)) + + +if __name__ == '__main__': + unittest.main()