From d09698ae459912b4169837005786c74b87d046c8 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 14 Sep 2026 13:09:23 -0400 Subject: [PATCH 1/4] esp: implement ESP with optional payload decryption RFC 4303 ESP was advertised but absent: pcapkit/protocols/internet/ipsec.py claimed id() == ('AH', 'ESP'), the docs carried a broken xref to the class, and IP protocol 50 fell through to Raw. The only source was a 98-line sketch under NotImplemented/ whose read() body was `pass`. Parsing splits at the SA boundary, which the wire does not carry. Without context ESP reports SPI and sequence and hands the remainder over as opaque ciphertext, guessing no trailer and never raising. With an SA it derives the ICV split, decrypts, strips padding by Pad Length and dispatches the plaintext through Next Header, so an ESP-tunnelled datagram decodes as its inner protocol. Getting that context to a protocol needed a channel, since nothing user-supplied reached one during extraction: pcapkit/corekit/context.py keys caller-supplied state on Protocol.id(), Extractor and extract() take a context= argument, and _import_next_layer threads it down as __context__. Keyed on Protocol.id() rather than made ESP-specific, so any protocol needing caller state can use it. Ciphers via cryptography, declared optional: AES-CBC, AES-GCM and NULL, with HMAC-SHA1/SHA2 integrity, per RFC 8221's mandatory-to-implement set. pcapkit imports and works without it, and ESP degrades to the no-keys path. Extended sequence numbers, TFC padding and anti-replay are not implemented; an ESN packet fails its integrity check cleanly rather than being decoded. Tests use published vectors - RFC 3602 cases 5 and 7, draft-mcgrew-gcm-test-01 - and cover wrong keys, a truncated ICV, and that keys stay out of dumps and reprs. --- Pipfile | 1 + docs/source/pcapkit/corekit/context.rst | 29 + docs/source/pcapkit/corekit/index.rst | 5 +- .../source/pcapkit/protocols/internet/esp.rst | 98 ++ .../pcapkit/protocols/internet/index.rst | 3 +- .../pcapkit/protocols/internet/ipsec.rst | 3 +- docs/source/pep.rst | 14 +- pcapkit/__init__.py | 2 +- pcapkit/corekit/context.py | 246 +++ pcapkit/foundation/engines/dpkt.py | 11 +- pcapkit/foundation/engines/pcap.py | 3 +- pcapkit/foundation/engines/pcapng.py | 1 + pcapkit/foundation/engines/pyshark.py | 9 + pcapkit/foundation/engines/scapy.py | 11 +- pcapkit/foundation/extraction.py | 19 +- pcapkit/interface/core.py | 20 +- pcapkit/protocols/__init__.py | 2 +- pcapkit/protocols/data/internet/__init__.py | 6 + pcapkit/protocols/data/internet/esp.py | 69 + .../protocols/internet/NotImplemented/esp.py | 97 -- pcapkit/protocols/internet/__init__.py | 5 +- pcapkit/protocols/internet/esp.py | 1336 +++++++++++++++++ pcapkit/protocols/internet/internet.py | 6 +- pcapkit/protocols/internet/ipsec.py | 4 +- pcapkit/protocols/internet/ipv4.py | 15 +- pcapkit/protocols/internet/ipv6.py | 3 +- pcapkit/protocols/protocol.py | 61 +- pcapkit/protocols/schema/internet/__init__.py | 6 + pcapkit/protocols/schema/internet/esp.py | 46 + pyproject.toml | 3 + .../engines/test_runtime_engines.py | 6 + tests/protocols/internet/test_esp_unit.py | 931 ++++++++++++ 32 files changed, 2945 insertions(+), 126 deletions(-) create mode 100644 docs/source/pcapkit/corekit/context.rst create mode 100644 docs/source/pcapkit/protocols/internet/esp.rst create mode 100644 pcapkit/corekit/context.py create mode 100644 pcapkit/protocols/data/internet/esp.py delete mode 100644 pcapkit/protocols/internet/NotImplemented/esp.py create mode 100644 pcapkit/protocols/internet/esp.py create mode 100644 pcapkit/protocols/schema/internet/esp.py create mode 100644 tests/protocols/internet/test_esp_unit.py 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/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..8ecdf4691b --- /dev/null +++ b/docs/source/pcapkit/protocols/internet/esp.rst @@ -0,0 +1,98 @@ +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 +-------------------- + +.. autoclass:: pcapkit.protocols.internet.esp.Cipher + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.internet.esp.Integrity + :members: + :show-inheritance: + +.. 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/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/corekit/context.py b/pcapkit/corekit/context.py new file mode 100644 index 0000000000..a711ce7412 --- /dev/null +++ b/pcapkit/corekit/context.py @@ -0,0 +1,246 @@ +# -*- 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 + + 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..d241f570d8 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 " + f"implementations, so the caller supplied parsing context " + f"'context={ext._exctx!r}' 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..060b202317 100644 --- a/pcapkit/foundation/engines/pyshark.py +++ b/pcapkit/foundation/engines/pyshark.py @@ -83,6 +83,9 @@ 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 @@ -92,6 +95,12 @@ def run(self) -> 'None': 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 " + f"implementations, so the caller supplied parsing context " + f"'context={ext._exctx!r}' 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) diff --git a/pcapkit/foundation/engines/scapy.py b/pcapkit/foundation/engines/scapy.py index bc52ea6536..bf527f66aa 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 " + f"implementations, so the caller supplied parsing context " + f"'context={ext._exctx!r}' 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..ca727543e4 --- /dev/null +++ b/pcapkit/protocols/internet/esp.py @@ -0,0 +1,1336 @@ +# -*- 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)) + +Supported algorithms +-------------------- + +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`: + +============================ =================== ============ ================================== +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** not implemented +``ENCR_CHACHA20_POLY1305`` SHOULD **no** not implemented +``ENCR_3DES`` SHOULD NOT **no** deliberately omitted +DES, Blowfish, 3IDEA MUST NOT **no** deliberately omitted +============================ =================== ============ ================================== + +"DES, Blowfish, 3IDEA" above covers ``ENCR_DES``, ``ENCR_DES_IV64``, +``ENCR_DES_IV32``, ``ENCR_BLOWFISH`` and ``ENCR_3IDEA``. + +============================ =================== ============ ================================== +Integrity :rfc:`8221` status Implemented Notes +============================ =================== ============ ================================== +``AUTH_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** not implemented +``AUTH_AES_*_GMAC`` MAY **no** not implemented +MD5, DES-MAC, KPDK-MD5 MUST NOT **no** deliberately omitted +============================ =================== ============ ================================== + +"MD5, DES-MAC, KPDK-MD5" above covers ``AUTH_HMAC_MD5_96``, +``AUTH_DES_MAC`` and ``AUTH_KPDK_MD5``. + +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, overload + +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', '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 + +#: Sentinel for the not-yet-attempted :mod:`cryptography` import. +_CRYPTO_UNSET = object() + +#: Cached :mod:`cryptography` primitives, c.f. :func:`load_cryptography`. +_CRYPTO = _CRYPTO_UNSET # 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 _CRYPTO_UNSET: + 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 registries. +############################################################################## + + +class Cipher(enum.IntEnum): + """ESP encryption algorithms. + + Values are the IKEv2 *Transform Type 1 (Encryption Algorithm)* IDs, so + that they line up with what an IKE exchange or a key log would name. + Only the members listed here are implemented; see the module docstring + for what is deliberately left out and why. + + """ + + #: ``ENCR_NULL`` -- no encryption [:rfc:`2410`]. + NULL = 11 + #: ``ENCR_AES_CBC`` -- AES in CBC mode [:rfc:`3602`]. + AES_CBC = 12 + #: ``ENCR_AES_GCM_8`` -- AES-GCM with an 8-octet ICV [:rfc:`4106`]. + AES_GCM_8 = 18 + #: ``ENCR_AES_GCM_12`` -- AES-GCM with a 12-octet ICV [:rfc:`4106`]. + AES_GCM_12 = 19 + #: ``ENCR_AES_GCM_16`` -- AES-GCM with a 16-octet ICV [:rfc:`4106`]. + AES_GCM_16 = 20 + + @property + def is_aead(self) -> 'bool': + """Whether the algorithm is a combined mode (AEAD) algorithm.""" + return self in (Cipher.AES_GCM_8, Cipher.AES_GCM_12, Cipher.AES_GCM_16) + + @property + def iv_length(self) -> 'int': + """Length of the explicit IV carried at the head of the payload data.""" + if self is Cipher.AES_CBC: + return 16 + if self.is_aead: + return 8 + return 0 + + @property + def block_size(self) -> '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. + + """ + return 16 if self is Cipher.AES_CBC else 1 + + @property + def icv_length(self) -> 'int': + """Length of the ICV produced by the algorithm itself (AEAD only).""" + if self is Cipher.AES_GCM_8: + return 8 + if self is Cipher.AES_GCM_12: + return 12 + if self is Cipher.AES_GCM_16: + return 16 + return 0 + + @property + def key_sizes(self) -> 'tuple[int, ...]': + """Permitted lengths of the AES key, in octets, excluding any salt.""" + if self is Cipher.NULL: + return (0,) + return (16, 24, 32) + + @property + def salt_length(self) -> 'int': + """Length of the salt taken from the keying material [:rfc:`4106` §8.1].""" + return 4 if self.is_aead else 0 + + @property + def requires_cryptography(self) -> 'bool': + """Whether the algorithm needs the optional |cryptography|_ dependency.""" + return self is not Cipher.NULL + + @classmethod + def get(cls, value: 'Cipher | str | int') -> 'Cipher': + """Coerce ``value`` into a :class:`Cipher` member. + + Args: + value: A member, an IKEv2 transform ID, or a name such as + ``'AES-CBC'``, ``'aes_gcm_16'`` or ``'ENCR_AES_GCM_16'``. + + Returns: + The corresponding member. + + Raises: + ProtocolError: If ``value`` names no supported algorithm. + + """ + if isinstance(value, cls): + return value + if isinstance(value, int): + try: + return cls(value) + except ValueError: + raise ProtocolError(f'unsupported ESP encryption algorithm: {value}') from None + + name = str(value).upper().replace('-', '_') + if name.startswith('ENCR_'): + name = name[5:] + try: + return cls[name] + except KeyError: + raise ProtocolError(f'unsupported ESP encryption algorithm: {value!r}') from None + + +class Integrity(enum.IntEnum): + """ESP integrity (authentication) algorithms. + + Values are the IKEv2 *Transform Type 3 (Integrity Algorithm)* IDs. + + """ + + #: ``AUTH_NONE`` -- no separate integrity algorithm; valid only with an + #: AEAD encryption algorithm, or for an unprotected SA. + NONE = 0 + #: ``AUTH_HMAC_SHA1_96`` [:rfc:`2404`]. + HMAC_SHA1_96 = 2 + #: ``AUTH_HMAC_SHA2_256_128`` [:rfc:`4868`]. + HMAC_SHA2_256_128 = 12 + #: ``AUTH_HMAC_SHA2_384_192`` [:rfc:`4868`]. + HMAC_SHA2_384_192 = 13 + #: ``AUTH_HMAC_SHA2_512_256`` [:rfc:`4868`]. + HMAC_SHA2_512_256 = 14 + + @property + def digest(self) -> 'Optional[str]': + """Name of the underlying hash, for :func:`hmac.new`.""" + return { + Integrity.HMAC_SHA1_96: 'sha1', + Integrity.HMAC_SHA2_256_128: 'sha256', + Integrity.HMAC_SHA2_384_192: 'sha384', + Integrity.HMAC_SHA2_512_256: 'sha512', + }.get(self) + + @property + def icv_length(self) -> 'int': + """Length of the truncated ICV, in octets.""" + return { + Integrity.HMAC_SHA1_96: 12, + Integrity.HMAC_SHA2_256_128: 16, + Integrity.HMAC_SHA2_384_192: 24, + Integrity.HMAC_SHA2_512_256: 32, + }.get(self, 0) + + @property + def key_size(self) -> 'int': + """Key length required by the specification, in octets.""" + return { + Integrity.HMAC_SHA1_96: 20, + Integrity.HMAC_SHA2_256_128: 32, + Integrity.HMAC_SHA2_384_192: 48, + Integrity.HMAC_SHA2_512_256: 64, + }.get(self, 0) + + @classmethod + def get(cls, value: 'Integrity | str | int') -> 'Integrity': + """Coerce ``value`` into an :class:`Integrity` member. + + Args: + value: A 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 corresponding member. + + Raises: + ProtocolError: If ``value`` names no supported algorithm. + + """ + if isinstance(value, cls): + return value + if isinstance(value, int): + try: + return cls(value) + except ValueError: + raise ProtocolError(f'unsupported ESP integrity algorithm: {value}') from None + + name = str(value).upper().replace('-', '_') + if name.startswith('AUTH_'): + name = name[5:] + # accept the RFC 4868 spelling ``HMAC_SHA_256_128`` as well as the + # IKEv2 spelling ``HMAC_SHA2_256_128`` + name = name.replace('HMAC_SHA_', 'HMAC_SHA2_') + if name in ('HMAC_SHA2_1_96', 'HMAC_SHA2_1'): + name = 'HMAC_SHA1_96' + try: + return cls[name] + except KeyError: + raise ProtocolError(f'unsupported ESP integrity algorithm: {value!r}') from None + + +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:`Cipher.get`. + 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:`Integrity.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.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 + #: Cipher: Encryption algorithm. + self.encryption = Cipher.get(encryption) + #: Integrity: Integrity algorithm. + self.integrity = Integrity.get(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.encryption.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.encryption, encryption_key, salt) + self.__key__ = key + self.__integrity_key__ = bytes(integrity_key) + + if self.integrity is not Integrity.NONE: + expected = self.integrity.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.encryption.is_aead: + return self.encryption.icv_length + return self.integrity.icv_length + + @property + def authenticated(self) -> 'bool': + """Whether the SA provides any integrity protection at all.""" + return self.encryption.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.encryption.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.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 + if cipher is Cipher.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 = cipher.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.AES_CBC: + if not ciphertext or len(ciphertext) % cipher.block_size: + raise ProtocolError(f'ESP ciphertext of {len(ciphertext)} octets is not a ' + f'positive multiple of the {cipher.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 + if cipher is Cipher.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 = cipher.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.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(cipher: 'Cipher', material: 'bytes', + salt: 'Optional[bytes]') -> 'tuple[bytes, bytes]': + """Split keying material into the key and the AEAD salt. + + Args: + cipher: Encryption algorithm. + 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 = cipher.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'{cipher.name} needs a {salt_length}-octet salt, ' + f'got {len(salt)}') + if len(material) not in cipher.key_sizes: + raise ProtocolError(f'{cipher.name} needs a key of ' + f'{" or ".join(map(str, cipher.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') or {} + 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, + ) + + unavailable = association.unavailable() + if unavailable is not None: + return self._make_opaque( + spi, seq, total, data, ESPStatus.UNSUPPORTED, unavailable, + version=version, packet=packet, + ) + + 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'') + + # 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 or None, + 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.encryption.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..5aefb33f10 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,8 @@ 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. + self._exctx = ContextRegistry.make(kwargs.pop('__context__', None)) # type: ContextRegistry #: bool: If terminate parsing next layer of protocol. self._sigterm = self._check_term_threshold() @@ -763,6 +796,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 +1212,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/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..6ec648c3e3 --- /dev/null +++ b/tests/protocols/internet/test_esp_unit.py @@ -0,0 +1,931 @@ +"""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.protocols.internet.esp import Cipher + from pcapkit.utilities.exceptions import ProtocolError + + # IKEv2 transform type 1 identifiers + self.assertEqual(Cipher.NULL, 11) + self.assertEqual(Cipher.AES_CBC, 12) + self.assertEqual(Cipher.AES_GCM_8, 18) + self.assertEqual(Cipher.AES_GCM_12, 19) + self.assertEqual(Cipher.AES_GCM_16, 20) + + for spelling in ('AES-CBC', 'aes_cbc', 'ENCR_AES_CBC', 12, Cipher.AES_CBC): + with self.subTest(spelling=spelling): + self.assertIs(Cipher.get(spelling), Cipher.AES_CBC) + + self.assertFalse(Cipher.AES_CBC.is_aead) + self.assertTrue(Cipher.AES_GCM_16.is_aead) + self.assertEqual(Cipher.AES_CBC.iv_length, 16) + self.assertEqual(Cipher.AES_GCM_16.iv_length, 8) + self.assertEqual(Cipher.NULL.iv_length, 0) + self.assertEqual(Cipher.AES_CBC.block_size, 16) + self.assertEqual(Cipher.AES_GCM_16.block_size, 1) + self.assertEqual(Cipher.AES_GCM_8.icv_length, 8) + self.assertEqual(Cipher.AES_GCM_12.icv_length, 12) + self.assertEqual(Cipher.AES_GCM_16.icv_length, 16) + self.assertEqual(Cipher.AES_CBC.icv_length, 0) + self.assertEqual(Cipher.AES_GCM_16.salt_length, 4) + self.assertEqual(Cipher.AES_CBC.salt_length, 0) + self.assertFalse(Cipher.NULL.requires_cryptography) + self.assertTrue(Cipher.AES_CBC.requires_cryptography) + + # deliberately unimplemented: 3DES (13 is AES-CTR, 3DES is 3) + with self.assertRaises(ProtocolError): + Cipher.get('3DES') + with self.assertRaises(ProtocolError): + Cipher.get('CHACHA20_POLY1305') + with self.assertRaises(ProtocolError): + Cipher.get(3) + + def test_integrity_registry(self) -> None: + from pcapkit.protocols.internet.esp import Integrity + from pcapkit.utilities.exceptions import ProtocolError + + self.assertEqual(Integrity.NONE, 0) + self.assertEqual(Integrity.HMAC_SHA1_96, 2) + self.assertEqual(Integrity.HMAC_SHA2_256_128, 12) + self.assertEqual(Integrity.HMAC_SHA2_384_192, 13) + self.assertEqual(Integrity.HMAC_SHA2_512_256, 14) + + for spelling in ('HMAC-SHA2-256-128', 'AUTH_HMAC_SHA2_256_128', + 'hmac_sha_256_128', 12): + with self.subTest(spelling=spelling): + self.assertIs(Integrity.get(spelling), Integrity.HMAC_SHA2_256_128) + + # RFC 4868 truncation lengths and key sizes + self.assertEqual(Integrity.HMAC_SHA1_96.icv_length, 12) + self.assertEqual(Integrity.HMAC_SHA2_256_128.icv_length, 16) + self.assertEqual(Integrity.HMAC_SHA2_384_192.icv_length, 24) + self.assertEqual(Integrity.HMAC_SHA2_512_256.icv_length, 32) + self.assertEqual(Integrity.HMAC_SHA1_96.key_size, 20) + self.assertEqual(Integrity.HMAC_SHA2_512_256.key_size, 64) + self.assertEqual(Integrity.NONE.digest, None) + self.assertEqual(Integrity.HMAC_SHA2_256_128.digest, 'sha256') + + with self.assertRaises(ProtocolError): + Integrity.get('HMAC_MD5_96') + with self.assertRaises(ProtocolError): + Integrity.get('AES_XCBC_96') + + 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') + + ########################################################################## + # 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() From eaa9c3a634fd0aef942ac053f71f4f340571aa18 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 14 Sep 2026 13:46:11 -0400 Subject: [PATCH 2/4] esp: address review on the context channel and the crypto sentinel - The cached cryptography import used an object() sentinel for "not yet attempted"; NotImplemented now marks that state, with None still meaning attempted-and-absent, so the tri-state reads without a private object. - Three engine warnings interpolated the caller's context with !r. That is exactly the material this protocol keeps out of logs, and a caller's context need not have a safe __repr__, so the messages name the keyword instead. - ESP replaced a caller-supplied empty packet dict with a fresh one through `or {}`, and passed `packet or None` down to the next layer; both now test for None, as IPv4 and IPv6 do, so an intentionally empty dict survives. - ProtocolBase.__init__ adopts an already-normalised ContextRegistry instead of calling make(), which copies: every nested layer normalised what it was handed, so a capture paid a dict copy per protocol for nothing. make() keeps copying, since the public `context` property relies on it to hand out a registry that cannot reach into the protocol's own. Full suite: 467 passed, 4 skipped, 227 subtests passed. --- pcapkit/corekit/context.py | 4 ++++ pcapkit/foundation/engines/dpkt.py | 4 ++-- pcapkit/foundation/engines/pyshark.py | 4 ++-- pcapkit/foundation/engines/scapy.py | 4 ++-- pcapkit/protocols/internet/esp.py | 16 +++++++++------- pcapkit/protocols/protocol.py | 8 +++++++- 6 files changed, 26 insertions(+), 14 deletions(-) diff --git a/pcapkit/corekit/context.py b/pcapkit/corekit/context.py index a711ce7412..8866cb93db 100644 --- a/pcapkit/corekit/context.py +++ b/pcapkit/corekit/context.py @@ -173,6 +173,10 @@ def make(cls, value: 'Optional[ContextRegistry | ProtocolContext | Mapping[str, 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 diff --git a/pcapkit/foundation/engines/dpkt.py b/pcapkit/foundation/engines/dpkt.py index d241f570d8..0d9f91930b 100644 --- a/pcapkit/foundation/engines/dpkt.py +++ b/pcapkit/foundation/engines/dpkt.py @@ -105,8 +105,8 @@ def run(self) -> 'None': if ext._exctx: warn("'Extractor(engine=dpkt)' does not parse with pcapkit's own protocol " - f"implementations, so the caller supplied parsing context " - f"'context={ext._exctx!r}' is ignored", + "implementations, so the parsing context supplied through " + "'context=' is ignored", AttributeWarning, stacklevel=stacklevel()) # setup verbose handler diff --git a/pcapkit/foundation/engines/pyshark.py b/pcapkit/foundation/engines/pyshark.py index 060b202317..a6231ed6cb 100644 --- a/pcapkit/foundation/engines/pyshark.py +++ b/pcapkit/foundation/engines/pyshark.py @@ -97,8 +97,8 @@ def run(self) -> 'None': if ext._exctx: warn("'Extractor(engine='pyshark')' does not parse with pcapkit's own protocol " - f"implementations, so the caller supplied parsing context " - f"'context={ext._exctx!r}' is ignored", + "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): diff --git a/pcapkit/foundation/engines/scapy.py b/pcapkit/foundation/engines/scapy.py index bf527f66aa..70c177102f 100644 --- a/pcapkit/foundation/engines/scapy.py +++ b/pcapkit/foundation/engines/scapy.py @@ -92,8 +92,8 @@ def run(self) -> 'None': if ext._exctx: warn("'Extractor(engine=scapy)' does not parse with pcapkit's own protocol " - f"implementations, so the caller supplied parsing context " - f"'context={ext._exctx!r}' is ignored", + "implementations, so the parsing context supplied through " + "'context=' is ignored", AttributeWarning, stacklevel=stacklevel()) # setup verbose handler diff --git a/pcapkit/protocols/internet/esp.py b/pcapkit/protocols/internet/esp.py index ca727543e4..3f43cc18da 100644 --- a/pcapkit/protocols/internet/esp.py +++ b/pcapkit/protocols/internet/esp.py @@ -151,11 +151,11 @@ from pcapkit.protocols.protocol import ProtocolBase as Protocol -#: Sentinel for the not-yet-attempted :mod:`cryptography` import. -_CRYPTO_UNSET = object() - #: Cached :mod:`cryptography` primitives, c.f. :func:`load_cryptography`. -_CRYPTO = _CRYPTO_UNSET # type: Any +#: :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]]]': @@ -175,7 +175,7 @@ def load_cryptography() -> 'Optional[tuple[Any, Any, Any, Type[Exception]]]': """ global _CRYPTO # pylint: disable=global-statement - if _CRYPTO is _CRYPTO_UNSET: + if _CRYPTO is NotImplemented: try: from cryptography.exceptions import \ InvalidTag as _InvalidTag # pylint: disable=import-outside-toplevel @@ -956,7 +956,9 @@ def read(self, length: 'Optional[int]' = None, *, version: 'Literal[4, 6]' = 4, spi, seq = schema.spi, schema.seq total = 8 + len(data) - packet = kwargs.get('packet') or {} + 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 @@ -1034,7 +1036,7 @@ def read(self, length: 'Optional[int]' = None, *, version: 'Literal[4, 6]' = 4, padding=padding, plaintext=inner, ) - return self._decode_next_layer(esp, next_type, len(inner), packet=packet or None, + return self._decode_next_layer(esp, next_type, len(inner), packet=packet, version=version, payload=inner) def make(self, diff --git a/pcapkit/protocols/protocol.py b/pcapkit/protocols/protocol.py index 5aefb33f10..47deb1b146 100644 --- a/pcapkit/protocols/protocol.py +++ b/pcapkit/protocols/protocol.py @@ -546,7 +546,13 @@ def __init__(self, file: 'Optional[IO[bytes] | bytes]' = None, length: 'Optional #: 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. - self._exctx = ContextRegistry.make(kwargs.pop('__context__', None)) # type: ContextRegistry + # 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() From 37a7b95ac397616084389eb42e5571190197f5f9 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 14 Sep 2026 15:04:44 -0400 Subject: [PATCH 3/4] esp: generate the IKEv2 algorithm registries into const and vendor Cipher and Integrity were IANA registry enums living in the protocol module. They are IKEv2 Transform Type 1 and Type 3 transform IDs, so under the project's rule they belong in const with a vendor crawler, and they now do: pcapkit/const/esp/{cipher,integrity}.py generated by pcapkit/vendor/esp/, from ikev2-parameters-5.csv and -7.csv. Named per consumer, as every other const subpackage is, with both __init__ docstrings noting the registry is IKEv2's and ESP the only consumer so far. Neither CSV matches Vendor.process()'s default layout - column 2 is Status, not the reference - so each crawler carries its own process(). Registry names are kept verbatim and the prefix-stripped spelling is emitted as an alias, which is what keeps Cipher.AES_CBC resolving in the docstring examples elsewhere. Registration is not support. The registry contributes 36 ciphers and 15 integrity algorithms; pcapkit implements 5 of each, and that is now an explicit table in esp.py rather than properties on the enum, so a registered but unimplemented algorithm is refused at SA construction with a message naming what is implemented. Every value and parameter for the supported algorithms was compared against the old inline enums and is identical. ESPStatus stays in esp.py: it describes pcapkit's own decrypt outcome and appears in no registry. Also normalised the three pyshark engine warnings to 'Extractor(engine=pyshark)', matching dpkt and scapy, and fixed the adjacent "dose not support" typo. Full suite: 467 passed, 4 skipped, 227 subtests passed. --- docs/source/pcapkit/const/esp.rst | 59 +++ docs/source/pcapkit/const/index.rst | 1 + .../source/pcapkit/protocols/internet/esp.rst | 29 +- docs/source/pcapkit/vendor/esp.rst | 50 ++ docs/source/pcapkit/vendor/index.rst | 1 + pcapkit/const/__init__.py | 3 + pcapkit/const/esp/__init__.py | 39 ++ pcapkit/const/esp/cipher.py | 251 ++++++++++ pcapkit/const/esp/integrity.py | 140 ++++++ pcapkit/foundation/engines/pyshark.py | 6 +- pcapkit/protocols/internet/esp.py | 470 ++++++++++-------- pcapkit/vendor/__init__.py | 3 + pcapkit/vendor/esp/__init__.py | 33 ++ pcapkit/vendor/esp/cipher.py | 107 ++++ pcapkit/vendor/esp/integrity.py | 104 ++++ tests/protocols/internet/test_esp_unit.py | 144 ++++-- 16 files changed, 1189 insertions(+), 251 deletions(-) create mode 100644 docs/source/pcapkit/const/esp.rst create mode 100644 docs/source/pcapkit/vendor/esp.rst create mode 100644 pcapkit/const/esp/__init__.py create mode 100644 pcapkit/const/esp/cipher.py create mode 100644 pcapkit/const/esp/integrity.py create mode 100644 pcapkit/vendor/esp/__init__.py create mode 100644 pcapkit/vendor/esp/cipher.py create mode 100644 pcapkit/vendor/esp/integrity.py 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/protocols/internet/esp.rst b/docs/source/pcapkit/protocols/internet/esp.rst index 8ecdf4691b..faaac74bb9 100644 --- a/docs/source/pcapkit/protocols/internet/esp.rst +++ b/docs/source/pcapkit/protocols/internet/esp.rst @@ -65,14 +65,39 @@ SA context is supplied through the generic, protocol keyed channel of Algorithm Registries -------------------- -.. autoclass:: pcapkit.protocols.internet.esp.Cipher +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.Integrity +.. 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: 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/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/foundation/engines/pyshark.py b/pcapkit/foundation/engines/pyshark.py index a6231ed6cb..b9f591eece 100644 --- a/pcapkit/foundation/engines/pyshark.py +++ b/pcapkit/foundation/engines/pyshark.py @@ -91,12 +91,12 @@ def run(self) -> 'None': 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 " + 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()) @@ -104,7 +104,7 @@ def run(self) -> 'None': 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/protocols/internet/esp.py b/pcapkit/protocols/internet/esp.py index 3f43cc18da..601363e222 100644 --- a/pcapkit/protocols/internet/esp.py +++ b/pcapkit/protocols/internet/esp.py @@ -56,8 +56,33 @@ ) extraction = pcapkit.extract('esp.pcap', context=ESPContext(sa)) -Supported algorithms --------------------- +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 @@ -65,7 +90,9 @@ payload path, with a warning. The supported set is anchored on the *mandatory to implement* algorithms of -:rfc:`8221`: +: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 @@ -75,30 +102,42 @@ ``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** not implemented -``ENCR_CHACHA20_POLY1305`` SHOULD **no** not implemented -``ENCR_3DES`` SHOULD NOT **no** deliberately omitted -DES, Blowfish, 3IDEA MUST NOT **no** deliberately omitted +``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 ============================ =================== ============ ================================== -``AUTH_NONE`` MUST (AEAD only) yes for AEAD suites +``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** not implemented -``AUTH_AES_*_GMAC`` MAY **no** not implemented -MD5, DES-MAC, KPDK-MD5 MUST NOT **no** deliberately omitted +``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_DES_MAC`` and ``AUTH_KPDK_MD5``. +``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 ----------------- @@ -128,8 +167,10 @@ import hmac import ipaddress import os -from typing import TYPE_CHECKING, overload +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 @@ -139,7 +180,8 @@ from pcapkit.utilities.exceptions import ProtocolError, ProtocolUnbound from pcapkit.utilities.warnings import ProtocolWarning, warn -__all__ = ['ESP', 'ESPStatus', 'Cipher', 'Integrity', 'SecurityAssociation', 'ESPContext'] +__all__ = ['ESP', 'ESPStatus', 'Cipher', 'Integrity', 'CipherSuite', 'IntegritySuite', + 'CIPHER_SUITES', 'INTEGRITY_SUITES', 'SecurityAssociation', 'ESPContext'] if TYPE_CHECKING: from enum import IntEnum as StdlibEnum @@ -193,201 +235,219 @@ def load_cryptography() -> 'Optional[tuple[Any, Any, Any, Type[Exception]]]': ############################################################################## -# Algorithm registries. +# Algorithm support. ############################################################################## -class Cipher(enum.IntEnum): - """ESP encryption algorithms. +def _resolve(registry: 'Type[Cipher] | Type[Integrity]', value: 'Any', + prefix: 'str', kind: 'str') -> 'Any': + """Resolve ``value`` to a member of an IKEv2 transform registry. - Values are the IKEv2 *Transform Type 1 (Encryption Algorithm)* IDs, so - that they line up with what an IKE exchange or a key log would name. - Only the members listed here are implemented; see the module docstring - for what is deliberately left out and why. + 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``. - #: ``ENCR_NULL`` -- no encryption [:rfc:`2410`]. - NULL = 11 - #: ``ENCR_AES_CBC`` -- AES in CBC mode [:rfc:`3602`]. - AES_CBC = 12 - #: ``ENCR_AES_GCM_8`` -- AES-GCM with an 8-octet ICV [:rfc:`4106`]. - AES_GCM_8 = 18 - #: ``ENCR_AES_GCM_12`` -- AES-GCM with a 12-octet ICV [:rfc:`4106`]. - AES_GCM_12 = 19 - #: ``ENCR_AES_GCM_16`` -- AES-GCM with a 16-octet ICV [:rfc:`4106`]. - AES_GCM_16 = 20 + Raises: + ProtocolError: If ``value`` names nothing the registry holds. - @property - def is_aead(self) -> 'bool': - """Whether the algorithm is a combined mode (AEAD) algorithm.""" - return self in (Cipher.AES_GCM_8, Cipher.AES_GCM_12, Cipher.AES_GCM_16) + 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``. - @property - def iv_length(self) -> 'int': - """Length of the explicit IV carried at the head of the payload data.""" - if self is Cipher.AES_CBC: - return 16 - if self.is_aead: - return 8 - return 0 + Resolution is deliberately separate from *support*: it answers only + whether IANA registered the transform. See :meth:`CipherSuite.get`. - @property - def block_size(self) -> 'int': - """Cipher block size, in octets. + """ + 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 - :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. + 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}') - """ - return 16 if self is Cipher.AES_CBC else 1 - @property - def icv_length(self) -> 'int': - """Length of the ICV produced by the algorithm itself (AEAD only).""" - if self is Cipher.AES_GCM_8: - return 8 - if self is Cipher.AES_GCM_12: - return 12 - if self is Cipher.AES_GCM_16: - return 16 - return 0 +class CipherSuite(NamedTuple): + """Parameters of an ESP encryption algorithm :mod:`pcapkit` implements. - @property - def key_sizes(self) -> 'tuple[int, ...]': - """Permitted lengths of the AES key, in octets, excluding any salt.""" - if self is Cipher.NULL: - return (0,) - return (16, 24, 32) + 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. - @property - def salt_length(self) -> 'int': - """Length of the salt taken from the keying material [:rfc:`4106` §8.1].""" - return 4 if self.is_aead else 0 + """ - @property - def requires_cryptography(self) -> 'bool': - """Whether the algorithm needs the optional |cryptography|_ dependency.""" - return self is not Cipher.NULL + #: 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') -> 'Cipher': - """Coerce ``value`` into a :class:`Cipher` member. + def get(cls, value: 'Cipher | str | int') -> 'CipherSuite': + """Look up how to apply an encryption algorithm. Args: - value: A member, an IKEv2 transform ID, or a name such as - ``'AES-CBC'``, ``'aes_gcm_16'`` or ``'ENCR_AES_GCM_16'``. + value: A :class:`Cipher ` member, + an IKEv2 transform ID, or a name such as ``'AES-CBC'``, + ``'aes_cbc'`` or ``'ENCR_AES_CBC'``. Returns: - The corresponding member. + The suite describing how to apply the algorithm. Raises: - ProtocolError: If ``value`` names no supported algorithm. + 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. """ - if isinstance(value, cls): - return value - if isinstance(value, int): - try: - return cls(value) - except ValueError: - raise ProtocolError(f'unsupported ESP encryption algorithm: {value}') from None - - name = str(value).upper().replace('-', '_') - if name.startswith('ENCR_'): - name = name[5:] - try: - return cls[name] - except KeyError: - raise ProtocolError(f'unsupported ESP encryption algorithm: {value!r}') from None + 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 Integrity(enum.IntEnum): - """ESP integrity (authentication) algorithms. +class IntegritySuite(NamedTuple): + """Parameters of an ESP integrity algorithm :mod:`pcapkit` implements. - Values are the IKEv2 *Transform Type 3 (Integrity Algorithm)* IDs. + As with :class:`CipherSuite`, membership of :data:`INTEGRITY_SUITES` -- + not membership of the IANA registry -- is what makes an algorithm + supported. """ - #: ``AUTH_NONE`` -- no separate integrity algorithm; valid only with an - #: AEAD encryption algorithm, or for an unprotected SA. - NONE = 0 - #: ``AUTH_HMAC_SHA1_96`` [:rfc:`2404`]. - HMAC_SHA1_96 = 2 - #: ``AUTH_HMAC_SHA2_256_128`` [:rfc:`4868`]. - HMAC_SHA2_256_128 = 12 - #: ``AUTH_HMAC_SHA2_384_192`` [:rfc:`4868`]. - HMAC_SHA2_384_192 = 13 - #: ``AUTH_HMAC_SHA2_512_256`` [:rfc:`4868`]. - HMAC_SHA2_512_256 = 14 - - @property - def digest(self) -> 'Optional[str]': - """Name of the underlying hash, for :func:`hmac.new`.""" - return { - Integrity.HMAC_SHA1_96: 'sha1', - Integrity.HMAC_SHA2_256_128: 'sha256', - Integrity.HMAC_SHA2_384_192: 'sha384', - Integrity.HMAC_SHA2_512_256: 'sha512', - }.get(self) - - @property - def icv_length(self) -> 'int': - """Length of the truncated ICV, in octets.""" - return { - Integrity.HMAC_SHA1_96: 12, - Integrity.HMAC_SHA2_256_128: 16, - Integrity.HMAC_SHA2_384_192: 24, - Integrity.HMAC_SHA2_512_256: 32, - }.get(self, 0) - - @property - def key_size(self) -> 'int': - """Key length required by the specification, in octets.""" - return { - Integrity.HMAC_SHA1_96: 20, - Integrity.HMAC_SHA2_256_128: 32, - Integrity.HMAC_SHA2_384_192: 48, - Integrity.HMAC_SHA2_512_256: 64, - }.get(self, 0) + #: 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') -> 'Integrity': - """Coerce ``value`` into an :class:`Integrity` member. + def get(cls, value: 'Integrity | str | int') -> 'IntegritySuite': + """Look up how to apply an integrity algorithm. Args: - value: A member, an IKEv2 transform ID, or a name such as - ``'HMAC-SHA-256-128'``, ``'hmac_sha2_256_128'`` or - ``'AUTH_HMAC_SHA2_256_128'``. + 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 corresponding member. + The suite describing how to apply the algorithm. Raises: - ProtocolError: If ``value`` names no supported algorithm. + ProtocolError: If ``value`` names no registered algorithm, or + names one :mod:`pcapkit` does not implement. """ - if isinstance(value, cls): - return value - if isinstance(value, int): - try: - return cls(value) - except ValueError: - raise ProtocolError(f'unsupported ESP integrity algorithm: {value}') from None - - name = str(value).upper().replace('-', '_') - if name.startswith('AUTH_'): - name = name[5:] - # accept the RFC 4868 spelling ``HMAC_SHA_256_128`` as well as the - # IKEv2 spelling ``HMAC_SHA2_256_128`` - name = name.replace('HMAC_SHA_', 'HMAC_SHA2_') - if name in ('HMAC_SHA2_1_96', 'HMAC_SHA2_1'): - name = 'HMAC_SHA1_96' - try: - return cls[name] - except KeyError: - raise ProtocolError(f'unsupported ESP integrity algorithm: {value!r}') from None + 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): @@ -424,13 +484,15 @@ class SecurityAssociation: 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:`Cipher.get`. + 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:`Integrity.get`. Must be - :attr:`Integrity.NONE` for an AEAD suite, which provides its own. + 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 @@ -462,7 +524,7 @@ class SecurityAssociation: """ def __init__(self, spi: 'Optional[int]' = None, *, - encryption: 'Cipher | str | int' = Cipher.NULL, + encryption: 'Cipher | str | int' = Cipher.ENCR_NULL, encryption_key: 'bytes' = b'', salt: 'Optional[bytes]' = None, integrity: 'Integrity | str | int' = Integrity.NONE, @@ -475,27 +537,31 @@ def __init__(self, spi: 'Optional[int]' = None, *, #: Optional[int]: Security Parameters Index, or :data:`None` for any. self.spi = spi - #: Cipher: Encryption algorithm. - self.encryption = Cipher.get(encryption) - #: Integrity: Integrity algorithm. - self.integrity = Integrity.get(integrity) + #: 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.encryption.is_aead and self.integrity is not Integrity.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.encryption, encryption_key, salt) + 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.key_size + 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 ' @@ -535,14 +601,14 @@ 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.encryption.is_aead: - return self.encryption.icv_length - return self.integrity.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.encryption.is_aead or self.integrity is not Integrity.NONE + return self.cipher_suite.is_aead or self.integrity is not Integrity.NONE ########################################################################## # Methods. @@ -590,7 +656,7 @@ def unavailable(self) -> 'Optional[str]': A reason the SA cannot be applied, or :data:`None` when it can. """ - if self.encryption.requires_cryptography and load_cryptography() is None: + 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 @@ -615,7 +681,7 @@ def compute_icv(self, spi: 'int', seq: 'int', body: 'bytes') -> 'bytes': ProtocolError: If the SA has no separate integrity algorithm. """ - digest = self.integrity.digest + digest = self.integrity_suite.digest if digest is None: raise ProtocolError(f'{self.integrity.name} computes no ICV') @@ -647,7 +713,8 @@ def decrypt(self, spi: 'int', seq: 'int', body: 'bytes', icv: 'bytes') -> 'bytes """ cipher = self.encryption - if cipher is Cipher.NULL: + suite = self.cipher_suite + if cipher is Cipher.ENCR_NULL: return body crypto = load_cryptography() @@ -656,16 +723,16 @@ def decrypt(self, spi: 'int', seq: 'int', body: 'bytes', icv: 'bytes') -> 'bytes f'which is not installed') crypto_cipher, algorithms, modes, _ = crypto - iv_length = cipher.iv_length + 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.AES_CBC: - if not ciphertext or len(ciphertext) % cipher.block_size: + 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 {cipher.block_size}-octet ' + 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() @@ -708,7 +775,8 @@ def encrypt(self, spi: 'int', seq: 'int', plaintext: 'bytes', """ cipher = self.encryption - if cipher is Cipher.NULL: + suite = self.cipher_suite + if cipher is Cipher.ENCR_NULL: return plaintext, b'' crypto = load_cryptography() @@ -717,13 +785,13 @@ def encrypt(self, spi: 'int', seq: 'int', plaintext: 'bytes', f'which is not installed') crypto_cipher, algorithms, modes, _ = crypto - iv_length = cipher.iv_length + 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.AES_CBC: + 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'' @@ -739,12 +807,12 @@ def encrypt(self, spi: 'int', seq: 'int', plaintext: 'bytes', ########################################################################## @staticmethod - def _split_key(cipher: 'Cipher', material: 'bytes', + def _split_key(suite: 'CipherSuite', material: 'bytes', salt: 'Optional[bytes]') -> 'tuple[bytes, bytes]': """Split keying material into the key and the AEAD salt. Args: - cipher: Encryption algorithm. + suite: Encryption algorithm parameters. material: Keying material as supplied by the caller. salt: Explicit salt, if the caller kept it separate. @@ -756,7 +824,7 @@ def _split_key(cipher: 'Cipher', material: 'bytes', """ material = bytes(material) - salt_length = cipher.salt_length + salt_length = suite.salt_length if salt is None: # RFC 4106 s8.1: the last four octets of the keying material are @@ -769,11 +837,11 @@ def _split_key(cipher: 'Cipher', material: 'bytes', salt = bytes(salt) if len(salt) != salt_length: - raise ProtocolError(f'{cipher.name} needs a {salt_length}-octet salt, ' + raise ProtocolError(f'{suite.cipher.name} needs a {salt_length}-octet salt, ' f'got {len(salt)}') - if len(material) not in cipher.key_sizes: - raise ProtocolError(f'{cipher.name} needs a key of ' - f'{" or ".join(map(str, cipher.key_sizes))} octets, ' + 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 @@ -1113,7 +1181,7 @@ def make(self, reversed=next_reversed, pack=False) plain = self._payload_bytes(payload) - align = max(association.encryption.block_size, 4) + 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: 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/tests/protocols/internet/test_esp_unit.py b/tests/protocols/internet/test_esp_unit.py index 6ec648c3e3..6ac40ee4ac 100644 --- a/tests/protocols/internet/test_esp_unit.py +++ b/tests/protocols/internet/test_esp_unit.py @@ -129,73 +129,127 @@ def setUp(self) -> None: purge_modules(['pcapkit']) def test_cipher_registry(self) -> None: - from pcapkit.protocols.internet.esp import Cipher + 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.NULL, 11) - self.assertEqual(Cipher.AES_CBC, 12) - self.assertEqual(Cipher.AES_GCM_8, 18) - self.assertEqual(Cipher.AES_GCM_12, 19) - self.assertEqual(Cipher.AES_GCM_16, 20) + 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(Cipher.get(spelling), Cipher.AES_CBC) - - self.assertFalse(Cipher.AES_CBC.is_aead) - self.assertTrue(Cipher.AES_GCM_16.is_aead) - self.assertEqual(Cipher.AES_CBC.iv_length, 16) - self.assertEqual(Cipher.AES_GCM_16.iv_length, 8) - self.assertEqual(Cipher.NULL.iv_length, 0) - self.assertEqual(Cipher.AES_CBC.block_size, 16) - self.assertEqual(Cipher.AES_GCM_16.block_size, 1) - self.assertEqual(Cipher.AES_GCM_8.icv_length, 8) - self.assertEqual(Cipher.AES_GCM_12.icv_length, 12) - self.assertEqual(Cipher.AES_GCM_16.icv_length, 16) - self.assertEqual(Cipher.AES_CBC.icv_length, 0) - self.assertEqual(Cipher.AES_GCM_16.salt_length, 4) - self.assertEqual(Cipher.AES_CBC.salt_length, 0) - self.assertFalse(Cipher.NULL.requires_cryptography) - self.assertTrue(Cipher.AES_CBC.requires_cryptography) - - # deliberately unimplemented: 3DES (13 is AES-CTR, 3DES is 3) + 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): - Cipher.get('3DES') + CipherSuite.get('CHACHA20_POLY1305') with self.assertRaises(ProtocolError): - Cipher.get('CHACHA20_POLY1305') + CipherSuite.get(3) + # and a name that is in no registry at all with self.assertRaises(ProtocolError): - Cipher.get(3) + CipherSuite.get('ROT13') def test_integrity_registry(self) -> None: - from pcapkit.protocols.internet.esp import Integrity + 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.HMAC_SHA1_96, 2) - self.assertEqual(Integrity.HMAC_SHA2_256_128, 12) - self.assertEqual(Integrity.HMAC_SHA2_384_192, 13) - self.assertEqual(Integrity.HMAC_SHA2_512_256, 14) + 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(Integrity.get(spelling), Integrity.HMAC_SHA2_256_128) + self.assertIs(IntegritySuite.get(spelling).integrity, + Integrity.AUTH_HMAC_SHA2_256_128) - # RFC 4868 truncation lengths and key sizes - self.assertEqual(Integrity.HMAC_SHA1_96.icv_length, 12) - self.assertEqual(Integrity.HMAC_SHA2_256_128.icv_length, 16) - self.assertEqual(Integrity.HMAC_SHA2_384_192.icv_length, 24) - self.assertEqual(Integrity.HMAC_SHA2_512_256.icv_length, 32) - self.assertEqual(Integrity.HMAC_SHA1_96.key_size, 20) - self.assertEqual(Integrity.HMAC_SHA2_512_256.key_size, 64) - self.assertEqual(Integrity.NONE.digest, None) - self.assertEqual(Integrity.HMAC_SHA2_256_128.digest, 'sha256') + 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): - Integrity.get('HMAC_MD5_96') + IntegritySuite.get('AES_XCBC_96') with self.assertRaises(ProtocolError): - Integrity.get('AES_XCBC_96') + IntegritySuite.get('HMAC_SHA3_256') def test_security_association_validation(self) -> None: from pcapkit.protocols.internet.esp import Cipher, Integrity, SecurityAssociation From 2d3c7db7aa8e9276c3629c0ed8c3f20753771479 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 14 Sep 2026 20:45:17 -0400 Subject: [PATCH 4/4] esp: split the ICV before deciding the payload is unsupported read() checked association.unavailable() ahead of the ICV split and the truncation check, so with cryptography missing two things went wrong: the ICV was never reported even though the security association declares its length independently of any crypto backend, and a packet too short to hold that ICV came back UNSUPPORTED instead of TRUNCATED. The length comes from the SA, so both the split and the truncation check are possible either way, and they now run first. An UNSUPPORTED packet reports the ICV it carries -- for a combined-mode algorithm that is the authentication tag, which a caller may well want to see -- and payload_data excludes it, matching every other status. Addresses Copilot's review comment on #378. --- pcapkit/protocols/internet/esp.py | 21 +++++++++----- tests/protocols/internet/test_esp_unit.py | 35 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/pcapkit/protocols/internet/esp.py b/pcapkit/protocols/internet/esp.py index 601363e222..cbf1b60fae 100644 --- a/pcapkit/protocols/internet/esp.py +++ b/pcapkit/protocols/internet/esp.py @@ -1037,13 +1037,13 @@ def read(self, length: 'Optional[int]' = None, *, version: 'Literal[4, 6]' = 4, version=version, packet=packet, warning=False, ) - unavailable = association.unavailable() - if unavailable is not None: - return self._make_opaque( - spi, seq, total, data, ESPStatus.UNSUPPORTED, unavailable, - version=version, packet=packet, - ) - + # 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( @@ -1055,6 +1055,13 @@ def read(self, length: 'Optional[int]' = None, *, version: 'Literal[4, 6]' = 4, 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: diff --git a/tests/protocols/internet/test_esp_unit.py b/tests/protocols/internet/test_esp_unit.py index 6ac40ee4ac..a308917282 100644 --- a/tests/protocols/internet/test_esp_unit.py +++ b/tests/protocols/internet/test_esp_unit.py @@ -735,6 +735,41 @@ def test_degrades_without_cryptography(self) -> None: 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. ##########################################################################