diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 93b7faca60..e621ef1633 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -852,16 +852,16 @@ These protocols parse a checksum or CRC field — :class:`~pcapkit.protocols.transport.udp.UDP` — and SCTP was the first to check whether the value is *right*, through :attr:`SCTP.checksum_valid `. -IPv4, OSPF, TCP, UDP and DCCP check it too, as do ICMP and ICMPv6, as the -one's-complement half below describes. For GRE, HIP, HOPOPT, IGMP, IPv6-Opts, IPX -and MH the field is recorded and never questioned, so a corrupted capture parses -as cleanly as an intact one; verifying GRE's and IGMP's is :issue:`2618`. +IPv4, OSPF, TCP, UDP and DCCP check it too, as do ICMP, ICMPv6, IGMP and GRE, as +the one's-complement half below describes. For HIP, HOPOPT, IPv6-Opts, IPX and MH +the field is recorded and never questioned, so a corrupted capture parses as +cleanly as an intact one. **These are different problems and they want separating**, because only one of them is cryptographic and the distinction decides what is actually hard. **The one's-complement Internet checksum**, which covers IPv4's header, TCP, UDP, -DCCP, ICMP/ICMPv6 and OSPF, needs no cryptography at all — it is sixteen-bit addition +DCCP, ICMP/ICMPv6, IGMP, GRE and OSPF, needs no cryptography at all — it is sixteen-bit addition with end-around carry, and implementing it is an afternoon. What blocks it is the **IP pseudo-header**: the TCP, UDP, DCCP and ICMPv6 checksums are computed over source and destination addresses, the protocol number and the payload length, all of @@ -922,8 +922,9 @@ from *wrong*, or it will cry wolf on the commonest capture there is — :class:`~pcapkit.protocols.internet.esp.ESPStatus`'s ``NO_SA`` member is the model for how to spell "expected, not an error". -**The one's-complement half is done** (:issue:`2470`, :issue:`2472` to -:issue:`2475`), on :class:`~pcapkit.protocols.misc.checksum.ip_pseudo.IP_Pseudo` +**The one's-complement half is done** for every protocol named above +(:issue:`2470`, :issue:`2472` to :issue:`2475`, :issue:`2489`, :issue:`2618`), on +:class:`~pcapkit.protocols.misc.checksum.ip_pseudo.IP_Pseudo` (:issue:`2471`) and :mod:`pcapkit.corekit.checksum.internet`, settled as below: the toggles by the owner on :issue:`2471`, the rest at implementation, as the offload question above asked; the owner accepted the toggles and the @@ -948,11 +949,21 @@ offload heuristic on :issue:`2470`. mandatory (:rfc:`8200#section-8.1`). * **A fragment is** :attr:`~pcapkit.corekit.checksum.internet.ChecksumStatus.NO_PSEUDO_HEADER`, - atomic ones aside: TCP, UDP, ICMP and ICMPv6 checksums cover all of the + atomic ones aside: TCP, UDP, ICMP, ICMPv6 and IGMP checksums cover all of the datagram's data, which it carries part of. OSPF's Packet Length says what its checksum covers, so it is verified when the fragment holds that much, and :attr:`~pcapkit.corekit.checksum.internet.ChecksumStatus.TRUNCATED` otherwise. +* **IGMP covers the whole IP payload in every version, and GRE its header and + payload only when** ``C`` **is set** (:rfc:`2236#section-2.5`, + :rfc:`3376#section-4.1.2`, :rfc:`2784#section-2.5`). The IGMPv1 rule follows + :rfc:`2236`, which updates :rfc:`1112`'s 8-octet one. A GRE header with ``C`` + clear is :attr:`~pcapkit.corekit.checksum.internet.ChecksumStatus.NOT_COMPUTED`, + and ``make()`` computes one when ``checksum_present`` asks without giving it. + Neither GRE nor the Linux cooked-capture header carries a length, so a GRE frame + the capture cut short is + :attr:`~pcapkit.corekit.checksum.internet.ChecksumStatus.INVALID`. Settled at + implementation (:issue:`2618`). * **DCCP covers what its Checksum Coverage gives** (:rfc:`4340#section-9.2`), over a pseudo-header that carries the whole packet's length, as TCP's does, so a fragment is @@ -1361,7 +1372,8 @@ and its signature parameters could start whenever, since :class:`~pcapkit.protocols.internet.esp.ESP` has already established both the keying-context channel and the report-rather-than-raise policy. The one's-complement half (IPv4, TCP, UDP, ICMP/ICMPv6, OSPF) is **done** -(:issue:`2470`, :issue:`2472` to :issue:`2475`), and DCCP's with it (:issue:`2489`). +(:issue:`2470`, :issue:`2472` to :issue:`2475`), and DCCP's with it (:issue:`2489`), +as are IGMP's and GRE's (:issue:`2618`). **IP pseudo-header — done** (:issue:`2471`). :class:`~pcapkit.protocols.misc.checksum.ip_pseudo.IP_Pseudo` is the diff --git a/docs/source/pcapkit/corekit/checksum/internet.rst b/docs/source/pcapkit/corekit/checksum/internet.rst index 72d1300620..17f365eabf 100644 --- a/docs/source/pcapkit/corekit/checksum/internet.rst +++ b/docs/source/pcapkit/corekit/checksum/internet.rst @@ -4,8 +4,8 @@ Internet Checksum .. module:: pcapkit.corekit.checksum.internet :mod:`pcapkit.corekit.checksum.internet` contains the one's-complement -Internet checksum (:rfc:`1071`) of the IPv4 header, TCP, UDP, ICMP, ICMPv6 -and OSPF. Parsing verifies a checksum only if +Internet checksum (:rfc:`1071`) of the IPv4 header, TCP, UDP, ICMP, ICMPv6, +IGMP, GRE and OSPF. Parsing verifies a checksum only if :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY` is on, and records the outcome as ``checksum_status``; construction computes one left out unless :data:`~pcapkit.utilities.config.CHECKSUM_COMPUTE` is off (:issue:`2471`). @@ -14,9 +14,10 @@ which a filter can silence alone (:issue:`2470`). It imports nothing from :mod:`pcapkit.protocols`, which builds on it (:issue:`2580`). A checksum over the IP pseudo-header is left to the enclosing -IP layer, see :mod:`pcapkit.protocols.misc.checksum.ip_pseudo`. ICMP's -checksum, over the message alone, is verified as it is parsed, unless the IP -layer it is parsed from is a fragment, see :func:`fragment_payload`. +IP layer, see :mod:`pcapkit.protocols.misc.checksum.ip_pseudo`. ICMP's, IGMP's +and GRE's checksums, over the message alone, are verified as it is parsed, +unless the IP layer it is parsed from is a fragment, see :func:`verify_message` +(:issue:`2618`). .. versionadded:: 1.6.0 @@ -28,4 +29,5 @@ layer it is parsed from is a fragment, see :func:`fragment_payload`. .. autofunction:: pcapkit.corekit.checksum.internet.compute_checksum .. autofunction:: pcapkit.corekit.checksum.internet.partial_checksum .. autofunction:: pcapkit.corekit.checksum.internet.verify_checksum +.. autofunction:: pcapkit.corekit.checksum.internet.verify_message .. autofunction:: pcapkit.corekit.checksum.internet.fragment_payload diff --git a/docs/source/pcapkit/protocols/internet/igmp.rst b/docs/source/pcapkit/protocols/internet/igmp.rst index a533fafdbb..439d3855f2 100644 --- a/docs/source/pcapkit/protocols/internet/igmp.rst +++ b/docs/source/pcapkit/protocols/internet/igmp.rst @@ -56,6 +56,7 @@ Octets Bits Name Description .. automethod:: _make_data .. automethod:: _classify + .. automethod:: _verify_checksum .. automethod:: _dispatch .. automethod:: _member_of_version diff --git a/docs/source/pcapkit/utilities/contextvars.rst b/docs/source/pcapkit/utilities/contextvars.rst index fe5a5a6097..8a34825b05 100644 --- a/docs/source/pcapkit/utilities/contextvars.rst +++ b/docs/source/pcapkit/utilities/contextvars.rst @@ -17,8 +17,5 @@ a module at any layer may import it; see :ref:`utilities-or-corekit`. .. autodata:: pcapkit.utilities.contextvars.frame_depth :no-value: -.. autodata:: pcapkit.utilities.contextvars.in_fragment - :no-value: - .. autodata:: pcapkit.utilities.contextvars.rebuilding :no-value: diff --git a/pcapkit/corekit/checksum/__init__.py b/pcapkit/corekit/checksum/__init__.py index 669aadef21..2d3b316290 100644 --- a/pcapkit/corekit/checksum/__init__.py +++ b/pcapkit/corekit/checksum/__init__.py @@ -14,10 +14,11 @@ """ from pcapkit.corekit.checksum.internet import (ChecksumStatus, compute_checksum, fragment_payload, - internet_checksum, partial_checksum, verify_checksum) + internet_checksum, partial_checksum, verify_checksum, + verify_message) __all__ = [ 'ChecksumStatus', 'internet_checksum', 'compute_checksum', 'partial_checksum', 'verify_checksum', - 'fragment_payload', + 'verify_message', 'fragment_payload', ] diff --git a/pcapkit/corekit/checksum/internet.py b/pcapkit/corekit/checksum/internet.py index d22f7a793d..5e360c717f 100644 --- a/pcapkit/corekit/checksum/internet.py +++ b/pcapkit/corekit/checksum/internet.py @@ -5,9 +5,9 @@ .. module:: pcapkit.corekit.checksum.internet :mod:`pcapkit.corekit.checksum.internet` contains the one's-complement -Internet checksum (:rfc:`1071`) of the IPv4 header, TCP, UDP, ICMP, ICMPv6 -and OSPF: :class:`ChecksumStatus`, the outcome a parse records, and the -functions that compute and verify the checksum. It imports nothing from +Internet checksum (:rfc:`1071`) of the IPv4 header, TCP, UDP, ICMP, ICMPv6, +IGMP, GRE and OSPF: :class:`ChecksumStatus`, the outcome a parse records, and +the functions that compute and verify the checksum. It imports nothing from :mod:`pcapkit.protocols`, which builds on it (:issue:`2580`). Verification is opt-in and computation opt-out, by @@ -17,20 +17,21 @@ which a filter can silence alone (:issue:`2470`). A checksum over the IP pseudo-header is left to the enclosing IP layer, see -:mod:`pcapkit.protocols.misc.checksum.ip_pseudo`. A checksum over the upper -layer alone, as ICMP's, is verified as it is parsed, unless the IP layer it is -parsed from is a fragment, see :func:`fragment_payload`. +:mod:`pcapkit.protocols.misc.checksum.ip_pseudo`. A checksum over the message +alone, as ICMP's, IGMP's and GRE's, is verified as it is parsed, unless the IP +layer it is parsed from is a fragment, see :func:`verify_message`. .. versionadded:: 1.6.0 """ import contextlib +import contextvars import enum import struct from typing import TYPE_CHECKING from pcapkit.corekit.enum import EnumLookup -from pcapkit.utilities.contextvars import in_fragment, rebuilding +from pcapkit.utilities.contextvars import rebuilding from pcapkit.utilities.exceptions import stacklevel from pcapkit.utilities.warnings import ChecksumWarning, warn @@ -40,9 +41,15 @@ __all__ = [ 'ChecksumStatus', 'internet_checksum', 'compute_checksum', 'partial_checksum', 'verify_checksum', - 'fragment_payload', + 'verify_message', 'fragment_payload', ] +#: contextvars.ContextVar[bool]: Whether the layer being parsed is the payload +#: of an IP fragment, as :func:`fragment_payload` sets it and +#: :func:`verify_message` reads it. Only this module uses it, so it stays here +#: (:issue:`2616`). +_in_fragment = contextvars.ContextVar('pcapkit.corekit.checksum.internet._in_fragment', default=False) + class ChecksumStatus(EnumLookup, enum.IntEnum): """Outcome of verifying a checksum, as recorded in ``checksum_status``. @@ -187,21 +194,52 @@ def verify_checksum(alias: 'str', data: 'bytes', field: 'bytes', *, length: 'Opt return ChecksumStatus.INVALID +def verify_message(alias: 'str', data: 'bytes', field: 'bytes', *, + length: 'Optional[int]' = None) -> 'ChecksumStatus': + """Verify the checksum ``field`` of a message that covers itself alone. + + ICMP's (:rfc:`792`), IGMP's (:rfc:`2236#section-2.3`, + :rfc:`3376#section-4.1.2`) and GRE's (:rfc:`2784#section-2.5`) checksums + cover the octets of their own message, with no pseudo-header, so they are + verified as the message is parsed, unless an IP fragment carries it; see + :func:`fragment_payload`. + + Args: + alias: Protocol alias, to name in the warning. + data: Octets the checksum covers, the checksum field included. + field: The checksum field, as on the wire. + length: Octets the checksum covers, as the headers declare it; the + length of ``data`` by default. + + Returns: + :attr:`~ChecksumStatus.NO_PSEUDO_HEADER` for the payload of an IP + fragment, otherwise as :func:`verify_checksum`. + + Warns: + ChecksumWarning: As :func:`verify_checksum` does. + + """ + if _in_fragment.get(): + return ChecksumStatus.NO_PSEUDO_HEADER + return verify_checksum(alias, data, field, length=length) + + @contextlib.contextmanager def fragment_payload(fragment: 'bool') -> 'Iterator[None]': """Mark the payload an IP layer parses as a fragment's, or not. - A checksum over the upper layer alone, as ICMP's (:rfc:`792`), covers all - of the datagram's data, which a fragment carries part of, so the upper - layer of one records :attr:`~ChecksumStatus.NO_PSEUDO_HEADER` rather than - being verified (:issue:`2470`). The nearest enclosing IP layer decides. + A checksum over the message alone, as ICMP's (:rfc:`792`), covers all + of the datagram's data, which a fragment carries part of, so + :func:`verify_message` records :attr:`~ChecksumStatus.NO_PSEUDO_HEADER` for + the upper layer of one rather than verifying it (:issue:`2470`). The + nearest enclosing IP layer decides. Args: fragment: Whether the IP layer is a fragment; an atomic one is not. """ - token = in_fragment.set(fragment) + token = _in_fragment.set(fragment) try: yield finally: - in_fragment.reset(token) + _in_fragment.reset(token) diff --git a/pcapkit/protocols/data/internet/igmp.py b/pcapkit/protocols/data/internet/igmp.py index 8a2a9e324e..b977b76b47 100644 --- a/pcapkit/protocols/data/internet/igmp.py +++ b/pcapkit/protocols/data/internet/igmp.py @@ -12,6 +12,7 @@ from typing_extensions import Literal from pcapkit.const.igmp.type import Type as Enum_Type + from pcapkit.corekit.checksum.internet import ChecksumStatus __all__ = ['IGMP'] @@ -50,5 +51,9 @@ class IGMP(Protocol): group: 'IPv4Address' if TYPE_CHECKING: + #: Outcome of verifying :attr:`checksum`, present only when + #: :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY` is on. + checksum_status: 'ChecksumStatus' + def __init__(self, version: 'Literal[1, 2]', type: 'Enum_Type', max_resp_time: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin checksum: 'bytes', group: 'IPv4Address') -> 'None': ... diff --git a/pcapkit/protocols/data/internet/igmpv3.py b/pcapkit/protocols/data/internet/igmpv3.py index b69a5cfb4a..72d7fa2a92 100644 --- a/pcapkit/protocols/data/internet/igmpv3.py +++ b/pcapkit/protocols/data/internet/igmpv3.py @@ -14,6 +14,7 @@ from pcapkit.const.igmp.record_type import RecordType as Enum_RecordType from pcapkit.const.igmp.type import Type as Enum_Type + from pcapkit.corekit.checksum.internet import ChecksumStatus __all__ = ['IGMPv3', 'Query', 'Report', 'GroupRecord'] @@ -43,6 +44,11 @@ class IGMPv3(Protocol): #: same way :attr:`~pcapkit.protocols.data.internet.esp.ESP.length` is. length: 'int' + if TYPE_CHECKING: + #: Outcome of verifying the message's ``checksum``, present only when + #: :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY` is on. + checksum_status: 'ChecksumStatus' + @info_final class Query(IGMPv3): diff --git a/pcapkit/protocols/data/link/gre.py b/pcapkit/protocols/data/link/gre.py index d0c8271f84..21af917237 100644 --- a/pcapkit/protocols/data/link/gre.py +++ b/pcapkit/protocols/data/link/gre.py @@ -10,6 +10,7 @@ from typing import Optional from pcapkit.const.reg.ethertype import EtherType + from pcapkit.corekit.checksum.internet import ChecksumStatus __all__ = ['GRE'] @@ -50,6 +51,12 @@ class GRE(Protocol): len: 'int' if TYPE_CHECKING: + #: Outcome of verifying :attr:`checksum`, present only when + #: :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY` is on; + #: :attr:`~pcapkit.corekit.checksum.internet.ChecksumStatus.NOT_COMPUTED` + #: when :attr:`checksum_present` is clear. + checksum_status: 'ChecksumStatus' + def __init__(self, checksum_present: 'bool', key_present: 'bool', # pylint: disable=unused-argument,super-init-not-called,multiple-statements sequence_present: 'bool', reserved: 'int', version: 'int', # pylint: disable=unused-argument protocol_type: 'EtherType', checksum: 'Optional[int]', # pylint: disable=unused-argument diff --git a/pcapkit/protocols/internet/icmpv4.py b/pcapkit/protocols/internet/icmpv4.py index 5a8dafe1e8..79f247b8bf 100644 --- a/pcapkit/protocols/internet/icmpv4.py +++ b/pcapkit/protocols/internet/icmpv4.py @@ -204,15 +204,9 @@ def read(self, length: 'Optional[int]' = None, *, # NOTE: The checksum covers the whole message, with no pseudo-header (RFC 792); # an IP fragment carries only part of one. if config.CHECKSUM_VERIFY: - from pcapkit.corekit.checksum.internet import ( # isort: skip # pylint: disable=import-outside-toplevel - ChecksumStatus, verify_checksum) - from pcapkit.utilities.contextvars import in_fragment # isort: skip # pylint: disable=import-outside-toplevel - if in_fragment.get(): - status = ChecksumStatus.NO_PSEUDO_HEADER - else: - status = verify_checksum(self.alias, self._data, schema.checksum, length=length) + from pcapkit.corekit.checksum.internet import verify_message # isort: skip # pylint: disable=import-outside-toplevel icmp.__update__([ - ('checksum_status', status), + ('checksum_status', verify_message(self.alias, self._data, schema.checksum, length=length)), ]) # NOTE: The quoted original datagram stays raw in the message body, diff --git a/pcapkit/protocols/internet/igmp.py b/pcapkit/protocols/internet/igmp.py index 73e5ec12e0..5784b507b1 100644 --- a/pcapkit/protocols/internet/igmp.py +++ b/pcapkit/protocols/internet/igmp.py @@ -19,7 +19,8 @@ octets give the class ``_dispatch`` picks, and fields are refused. :meth:`~pcapkit.protocols.internet.igmp.IGMP.from_data` gives the class the ``version`` of its data names, which each data model carries and no octet does -(:issue:`2574`). +(:issue:`2574`). Every version's checksum covers the whole message, with no +pseudo-header, so the base verifies it for each of them (:issue:`2618`). .. versionadded:: 1.6.0 @@ -34,6 +35,7 @@ from pcapkit.protocols.internet.internet import Internet from pcapkit.protocols.protocol import _PT, _ST from pcapkit.protocols.schema.internet.igmp import IGMP as Schema_IGMP +from pcapkit.utilities import config from pcapkit.utilities.exceptions import ProtocolError if TYPE_CHECKING: @@ -64,6 +66,11 @@ class IGMP(Internet[_PT, _ST], Generic[_PT, _ST], picks a class. :meth:`from_data` reads the ``version`` of its data. See :meth:`__new__`. + The checksum of every version is verified on read under + :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY`, unless the enclosing IP + layer is a fragment, see :meth:`_verify_checksum`; :meth:`make` computes + one it is not given. + """ ########################################################################## @@ -139,16 +146,17 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> '_PT': raise ProtocolError(f'{self.alias}: {schema.type!r} is not an IGMPv{self.version} ' f'message ({length} octets)') - igmp = Data_IGMP( + igmp = cast('_PT', Data_IGMP( version=self.version, # type: ignore[arg-type] type=schema.type, max_resp_time=schema.max_resp_time, checksum=schema.checksum, group=schema.group, - ) + )) + self._verify_checksum(igmp, schema.checksum, length) # NOTE: IGMPv1/v2 has no next-protocol field, so trailing octets stay # as ``Raw``. - return self._decode_next_layer(cast('_PT', igmp), None, length - self.length) + return self._decode_next_layer(igmp, None, length - self.length) def make(self, type: 'Enum_Type | StdlibEnum | AenumEnum | str | int' = Enum_Type.IGMP_Membership_Query, @@ -156,7 +164,7 @@ def make(self, type_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long type_reversed: 'bool' = False, max_resp_time: 'int' = 0, - checksum: 'bytes' = b'\x00\x00', + checksum: 'Optional[bytes]' = None, group: 'IPv4Address | str | bytes | int' = '0.0.0.0', # nosec: B104 payload: 'bytes | ProtocolBase | Schema' = b'', **kwargs: 'Any') -> '_ST': @@ -174,7 +182,10 @@ def make(self, max_resp_time: Max response time, in tenths of a second; zero for an IGMPv1 message. checksum: Checksum over the message with this field zeroed, no - pseudo-header (:rfc:`2236#section-2.3`). + pseudo-header (:rfc:`2236#section-2.3`); if :data:`None`, + computed over the message, ``payload`` included, when + :data:`~pcapkit.utilities.config.CHECKSUM_COMPUTE` is on, zero + when it is off. group: Group address; ``0.0.0.0`` for a General Query. payload: Payload of current instance. **kwargs: Arbitrary keyword arguments. @@ -193,13 +204,18 @@ def make(self, if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255: raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') - return Schema_IGMP( + schema = Schema_IGMP( type=type_value, # type: ignore[arg-type] max_resp_time=max_resp_time, - checksum=checksum, + checksum=b'\x00\x00' if checksum is None else checksum, group=group, payload=payload, - ) # type: ignore[return-value] + ) + + if checksum is None and config.CHECKSUM_COMPUTE: + from pcapkit.corekit.checksum.internet import compute_checksum # isort: skip # pylint: disable=import-outside-toplevel + schema.checksum = compute_checksum(schema.pack()) + return schema # type: ignore[return-value] @classmethod def from_data(cls, data: '_PT | dict[str, Any]', **kwargs: 'Any') -> 'Self': @@ -387,6 +403,29 @@ def _classify(cls, type: 'Enum_Type | int', max_resp_time: 'int', return 0 return 0 + def _verify_checksum(self, igmp: '_PT', checksum: 'bytes', length: 'int') -> 'None': + """Record on ``igmp`` the outcome of verifying the message's checksum. + + Only under :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY`, as + ``checksum_status``. Every version's checksum covers the whole message, + the entire IP payload, with no pseudo-header (:rfc:`2236#section-2.3`, + :rfc:`3376#section-4.1.2`, :rfc:`3376#section-4.2.2`). That holds for + IGMPv1 as well: :rfc:`1112` defines an 8-octet message, and :rfc:`2236`, + which updates it, sums a longer one whole (:rfc:`2236#section-2.5`). See + :func:`~pcapkit.corekit.checksum.internet.verify_message`. + + Args: + igmp: Parsed message data. + checksum: The checksum field, as on the wire. + length: The whole message's length, in octets. + + """ + if config.CHECKSUM_VERIFY: + from pcapkit.corekit.checksum.internet import verify_message # isort: skip # pylint: disable=import-outside-toplevel + igmp.__update__([ + ('checksum_status', verify_message(self.alias, self._data, checksum, length=length)), + ]) + @classmethod def _dispatch(cls, data: 'bytes', length: 'int') -> 'Type[ProtocolBase] | Type[Raw]': """Pick the class that parses the IGMP message at the start of ``data``. diff --git a/pcapkit/protocols/internet/igmpv3.py b/pcapkit/protocols/internet/igmpv3.py index c295f32a75..8ce18098ac 100644 --- a/pcapkit/protocols/internet/igmpv3.py +++ b/pcapkit/protocols/internet/igmpv3.py @@ -64,6 +64,7 @@ from pcapkit.protocols.schema.internet.igmpv3 import Query as Schema_Query from pcapkit.protocols.schema.internet.igmpv3 import QueryExtension as Schema_QueryExtension from pcapkit.protocols.schema.internet.igmpv3 import Report as Schema_Report +from pcapkit.utilities import config from pcapkit.utilities.exceptions import ProtocolError if TYPE_CHECKING: @@ -277,6 +278,7 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_IGMPv3' else: # pragma: no cover raise ProtocolError(f'{self.alias}: unsupported message type: {schema.type!r}') + self._verify_checksum(igmp, body.checksum, length) return self._decode_next_layer(igmp, None, len(trailing)) def make(self, @@ -286,7 +288,7 @@ def make(self, type_reversed: 'bool' = False, max_resp_code: 'Optional[int]' = None, max_resp_time: 'int' = 0, - checksum: 'bytes' = b'\x00\x00', + checksum: 'Optional[bytes]' = None, group: 'IPv4Address | str | bytes | int' = '0.0.0.0', # nosec: B104 resv: 'int' = 0, s_flag: 'bool' = False, @@ -321,7 +323,10 @@ def make(self, checksum: Checksum; the one's complement of the one's complement sum of the message, with this field taken as zero and no pseudo-header -- the whole IP payload (:rfc:`3376#section-4.1.2`, - :rfc:`3376#section-4.2.2`). + :rfc:`3376#section-4.2.2`). If :data:`None`, computed so, over + ``payload`` as well, when + :data:`~pcapkit.utilities.config.CHECKSUM_COMPUTE` is on, zero + when it is off. group: Group address; ``0.0.0.0`` for a General Query; only meaningful for a Query. resv: The 4 reserved high-order bits of the flags octet @@ -364,24 +369,24 @@ def make(self, or not 0 <= max_resp_time <= 255: raise ProtocolError(f'{self.alias}: invalid max_resp_time: {max_resp_time!r}') + field = b'\x00\x00' if checksum is None else checksum if type_value == Enum_Type.IGMPv3_Membership_Report: record_list = [] if records is None else [ self._make_group_record(record) if not isinstance(record, dict) else self._make_group_record(**record) for record in records ] - return Schema_IGMPv3( + schema = Schema_IGMPv3( type=type_value, body=Schema_Report( reserved_1=reserved_1, - checksum=checksum, + checksum=field, reserved_2=reserved_2, num_group_records=len(record_list), records=record_list, payload=payload, ), - ) # type: ignore[return-value] - - if type_value == Enum_Type.IGMP_Membership_Query: + ) + elif type_value == Enum_Type.IGMP_Membership_Query: if qqic is None: qqic = _encode_resp_code(0 if qqi is None else qqi) source_list = [] if sources is None else list(sources) @@ -396,18 +401,23 @@ def make(self, if max_resp_code is None: max_resp_code = _encode_resp_code(max_resp_time) - return Schema_IGMPv3( + schema = Schema_IGMPv3( type=type_value, body=Schema_Query( max_resp_code=max_resp_code, - checksum=checksum, + checksum=field, group=group, ext=ext, payload=payload, ), - ) # type: ignore[return-value] + ) + else: + raise ProtocolError(f'{self.alias}: unsupported message type: {type_value!r}') - raise ProtocolError(f'{self.alias}: unsupported message type: {type_value!r}') + if checksum is None and config.CHECKSUM_COMPUTE: + from pcapkit.corekit.checksum.internet import compute_checksum # isort: skip # pylint: disable=import-outside-toplevel + schema.body.checksum = compute_checksum(schema.pack()) + return schema ########################################################################## # Utilities. diff --git a/pcapkit/protocols/link/gre.py b/pcapkit/protocols/link/gre.py index 9fd66a6931..d8015d3f3a 100644 --- a/pcapkit/protocols/link/gre.py +++ b/pcapkit/protocols/link/gre.py @@ -53,7 +53,11 @@ Only the framing above is dissected. The Key and Sequence Number fields are recorded but not interpreted further -- their meaning (which traffic flow, and where in its sequence) is a matter for the tunnel endpoints, not something a -passive dissector can resolve from the header alone. RFC 2637's Enhanced GRE +passive dissector can resolve from the header alone. The Checksum, when ``C`` +is set, covers the header and the payload with no pseudo-header +(:rfc:`2784#section-2.5`); :meth:`~pcapkit.protocols.link.gre.GRE.read` +verifies it and :meth:`~pcapkit.protocols.link.gre.GRE.make` computes it +(:issue:`2618`). RFC 2637's Enhanced GRE (version 1, used by PPTP) has a different, incompatible layout from this point on -- a Key field split into Payload Length and Call ID, and an optional Acknowledgment Number -- and is out of scope; :meth:`read` refuses it rather @@ -69,6 +73,7 @@ from pcapkit.protocols.data.link.gre import GRE as Data_GRE from pcapkit.protocols.link.link import Link from pcapkit.protocols.schema.link.gre import GRE as Schema_GRE +from pcapkit.utilities import config from pcapkit.utilities.exceptions import ProtocolError if TYPE_CHECKING: @@ -95,6 +100,14 @@ class GRE(Link[Data_GRE, Schema_GRE], :class:`~pcapkit.protocols.link.frad.FRAD`, this class declares no registry of its own. + The checksum, present when ``C`` is set, is verified on read under + :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY`, and computed by + :meth:`make` when ``checksum_present`` asks for one without giving it. + Neither this header nor the Linux cooked-capture header before it states + a length, so the octets verified are the ones captured: a frame the + capture cut short reads as + :attr:`~pcapkit.corekit.checksum.internet.ChecksumStatus.INVALID`. + """ ########################################################################## @@ -195,11 +208,25 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_GRE': sequence_number=_sequence_number, len=_hdr_len, ) + + # NOTE: The checksum covers the header and the payload, with no + # pseudo-header (RFC 2784 section 2.5); with C clear there is none. + if config.CHECKSUM_VERIFY: + from pcapkit.corekit.checksum.internet import ( # isort: skip # pylint: disable=import-outside-toplevel + ChecksumStatus, verify_message) + if _checksum is None: + status = ChecksumStatus.NOT_COMPUTED + else: + status = verify_message(self.alias, self._data, _checksum.to_bytes(2, 'big'), length=length) + gre.__update__([ + ('checksum_status', status), + ]) return self._decode_next_layer(gre, schema.protocol_type, length - _hdr_len) def make(self, checksum: 'Optional[int]' = None, reserved1: 'Optional[int]' = None, + checksum_present: 'Optional[bool]' = None, key: 'Optional[int]' = None, sequence_number: 'Optional[int]' = None, reserved: 'int' = 0, @@ -216,13 +243,22 @@ def make(self, value is given, as :meth:`L2TPv2.make ` infers ``S`` from whether ``ns``/``nr`` are given: passing ``checksum`` sets ``C``, - ``key`` sets ``K``, and ``sequence_number`` sets ``S``. + ``key`` sets ``K``, and ``sequence_number`` sets ``S``. ``C`` can be + set without a ``checksum`` too, through ``checksum_present``, as + L2TPv2's ``length_flag`` sets ``L`` without a length. Args: - checksum: Checksum; setting this also sets ``C``. + checksum: Checksum; setting this also sets ``C``. If :data:`None` + with ``C`` set, computed over the header and ``payload`` + (:rfc:`2784#section-2.5`) when + :data:`~pcapkit.utilities.config.CHECKSUM_COMPUTE` is on, zero + when it is off. reserved1: Reserved1; only meaningful, and only accepted, when - ``checksum`` is also given. Defaults to ``0`` when - ``checksum`` is given and this is not. + ``C`` is set. Defaults to ``0`` when ``C`` is set and this is + not. + checksum_present: Whether ``C`` is set, and the Checksum and + Reserved1 fields present. When :data:`None`, ``C`` is set if + ``checksum`` is given. key: Key, :rfc:`2890`; setting this also sets ``K``. sequence_number: Sequence Number, :rfc:`2890`; setting this also sets ``S``. @@ -241,8 +277,8 @@ def make(self, Constructed packet data. Raises: - ProtocolError: If ``reserved1`` is given without ``checksum``; if - ``reserved`` sets a bit outside ``0x4FF8``; if ``version`` + ProtocolError: If ``checksum`` or ``reserved1`` is given with ``C`` + clear; if ``reserved`` sets a bit outside ``0x4FF8``; if ``version`` does not fit in 3 bits; or if ``checksum``, ``key`` or ``sequence_number`` is given but does not fit the width of its field (16 bits for ``checksum``, 32 bits for ``key`` and @@ -253,11 +289,15 @@ def make(self, namespace=protocol_type_namespace, reversed=protocol_type_reversed, pack=False) - checksum_present = checksum is not None + if checksum_present is None: + checksum_present = checksum is not None + elif not checksum_present and checksum is not None: + raise ProtocolError(f'{self.alias}: checksum given with checksum_present=False') if reserved1 is not None and not checksum_present: raise ProtocolError(f'{self.alias}: reserved1 given without checksum') if checksum_present: - checksum = self._make_uint(checksum, bits=16, name='checksum') + if checksum is not None: + checksum = self._make_uint(checksum, bits=16, name='checksum') if reserved1 is None: reserved1 = 0 else: @@ -275,7 +315,7 @@ def make(self, raise ProtocolError(f'{self.alias}: reserved bits outside 0x4FF8: {reserved:#06x}') self._make_uint(version, bits=3, name='version') - return Schema_GRE( + schema = Schema_GRE( flags={ 'checksum_present': checksum_present, 'reserved_1': (reserved >> 14) & 0b1, @@ -285,13 +325,18 @@ def make(self, 'version': version, }, protocol_type=protocol_type_, # type: ignore[arg-type] - checksum=checksum, + checksum=0 if checksum_present and checksum is None else checksum, reserved1=reserved1, key=key, sequence_number=sequence_number, payload=payload, ) + if checksum_present and checksum is None and config.CHECKSUM_COMPUTE: + from pcapkit.corekit.checksum.internet import internet_checksum # isort: skip # pylint: disable=import-outside-toplevel + schema.checksum = internet_checksum(schema.pack()) + return schema + ########################################################################## # Data models. ########################################################################## diff --git a/pcapkit/utilities/config.py b/pcapkit/utilities/config.py index 9de3fd4157..67029d218b 100644 --- a/pcapkit/utilities/config.py +++ b/pcapkit/utilities/config.py @@ -414,7 +414,8 @@ def read_flag(name: 'str', default: 'bool' = False) -> 'bool': ############################################################################### #: bool: Whether parsing verifies the one's-complement checksums, of the IPv4 -#: header, TCP, UDP, DCCP, ICMP, ICMPv6 and OSPF, :envvar:`PCAPKIT_CHECKSUM_VERIFY`. +#: header, TCP, UDP, DCCP, ICMP, ICMPv6, IGMP, GRE and OSPF, +#: :envvar:`PCAPKIT_CHECKSUM_VERIFY`. #: #: Off by default, since checksumming every packet costs time on a large #: capture (:issue:`2471`). When on, each records a diff --git a/pcapkit/utilities/contextvars.py b/pcapkit/utilities/contextvars.py index ee11579690..36bebda4d6 100644 --- a/pcapkit/utilities/contextvars.py +++ b/pcapkit/utilities/contextvars.py @@ -15,7 +15,7 @@ """ import contextvars -__all__ = ['beholding', 'frame_depth', 'in_fragment', 'rebuilding'] +__all__ = ['beholding', 'frame_depth', 'rebuilding'] #: contextvars.ContextVar[bool]: Whether a layer that raises is discarded by its #: caller, as :func:`~pcapkit.corekit.decorators.beholder` replaces it and @@ -31,10 +31,6 @@ #: :data:`~pcapkit.utilities.config.FRAME_LAYER_LIMIT`. frame_depth = contextvars.ContextVar('pcapkit.utilities.contextvars.frame_depth', default=0) -#: contextvars.ContextVar[bool]: Whether the layer being parsed is the payload -#: of an IP fragment, see :func:`~pcapkit.corekit.checksum.internet.fragment_payload`. -in_fragment = contextvars.ContextVar('pcapkit.utilities.contextvars.in_fragment', default=False) - #: contextvars.ContextVar[bool]: Whether a payload is being rebuilt by #: :meth:`Protocol._make_payload #: `, so that the layer diff --git a/tests/corekit/test_checksum_internet_2471_unit.py b/tests/corekit/test_checksum_internet_2471_unit.py index ba8a793fde..f5168ef8fe 100644 --- a/tests/corekit/test_checksum_internet_2471_unit.py +++ b/tests/corekit/test_checksum_internet_2471_unit.py @@ -124,6 +124,28 @@ def test_the_status_is_shaped_like_esp_status(self) -> None: ['VALID', 'INVALID', 'NOT_COMPUTED', 'TRUNCATED', 'NO_PSEUDO_HEADER']) self.assertIs(self.Status.get('NOT_COMPUTED'), self.Status.NOT_COMPUTED) + def test_a_message_is_verified_outside_a_fragment_only(self) -> None: + # GitHub issue #2618: ICMP, IGMP and GRE share verify_message. + def verify(data: bytes, field: bytes, **kwargs: object) -> 'tuple[object, list[tuple[type, str]]]': + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + status = self.checksum.verify_message('TEST', data, field, **kwargs) + return status, [(item.category, str(item.message)) + for item in caught if issubclass(item.category, self.ProtocolWarning)] + + bad = self.corrupt(b'\xbe\xef') + self.assertEqual(verify(self.data, self.field), (self.Status.VALID, [])) + self.assertEqual(verify(self.data, self.field, length=len(self.data) + 1), (self.Status.TRUNCATED, [])) + self.assertEqual(verify(bad, b'\xbe\xef'), self.verify(bad, b'\xbe\xef')) + with self.checksum.fragment_payload(True): + # Not verified, so a wrong checksum does not warn. + self.assertEqual(verify(bad, b'\xbe\xef'), (self.Status.NO_PSEUDO_HEADER, [])) + # An IP layer inside the fragment that is no fragment itself decides for its payload. + with self.checksum.fragment_payload(False): + self.assertEqual(verify(self.data, self.field), (self.Status.VALID, [])) + self.assertEqual(verify(self.data, self.field), (self.Status.NO_PSEUDO_HEADER, [])) + self.assertEqual(verify(self.data, self.field), (self.Status.VALID, [])) + if __name__ == '__main__': unittest.main() diff --git a/tests/protocols/internet/test_igmp_checksum_2618_unit.py b/tests/protocols/internet/test_igmp_checksum_2618_unit.py new file mode 100644 index 0000000000..c7e1c3eaad --- /dev/null +++ b/tests/protocols/internet/test_igmp_checksum_2618_unit.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +"""IGMPv1, IGMPv2 and IGMPv3 verify and compute their checksums. + +GitHub issue #2618: the checksum of every IGMP version covers the whole message, +the entire IP payload, with no pseudo-header (:rfc:`2236#section-2.3`, +:rfc:`2236#section-2.5`, :rfc:`3376#section-4.1.2`, :rfc:`3376#section-4.2.2`). +:class:`~pcapkit.protocols.internet.igmp.IGMP` verifies it on read for all three +versions under :data:`~pcapkit.utilities.config.CHECKSUM_VERIFY`, as ICMP does, and +``make()`` computes one it is not given under +:data:`~pcapkit.utilities.config.CHECKSUM_COMPUTE`. + +No capture under :file:`examples/captures/`, tracked or generated, carries IGMP, so +the messages are built here; their checksums are the hand-written sum of +``tests/foundation/_roundtrip.py``, and scapy's IGMP layers agree with what +``make()`` writes. + +""" +from __future__ import annotations + +import importlib.util +import unittest +import warnings +from unittest import mock + +from tests._support import reimport_once_per_class +from tests.foundation import _roundtrip as wire + +#: Test tier (#1958): hand-picked parse -> rebuild and make -> parse round trips. +__tier__ = 'ALL' + +HAS_SCAPY = importlib.util.find_spec('scapy') is not None + + +def message(head: bytes, rest: bytes) -> bytes: + """``head`` (type and Max Resp Code), the checksum over the whole message, then ``rest``.""" + return head + wire.checksum(head + b'\x00\x00' + rest).to_bytes(2, 'big') + rest + + +def with_checksum(packet: bytes, field: bytes, at: int = 2) -> bytes: + """``packet`` with ``field`` at ``at``.""" + return packet[:at] + field + packet[at + 2:] + + +#: A group record of type MODE_IS_INCLUDE for 239.1.1.1 with one source, 10.0.0.1. +RECORD = bytes.fromhex('01000001ef0101010a000001') + +#: One message per IGMP version and shape: its octets, the class it is read as, +#: and that class's alias. +MESSAGES = { + 'v1-query': (message(b'\x11\x00', bytes(4)), 'IGMPv1', 'IGMP'), + 'v1-report': (message(b'\x12\x00', bytes([224, 0, 0, 1])), 'IGMPv1', 'IGMP'), + 'v2-query': (message(b'\x11\x64', bytes(4)), 'IGMPv2', 'IGMPv2'), + 'v2-report': (message(b'\x16\x00', bytes([239, 1, 2, 3])), 'IGMPv2', 'IGMPv2'), + 'v2-leave': (message(b'\x17\x00', bytes([239, 1, 2, 3])), 'IGMPv2', 'IGMPv2'), + # A General Query with QRV 2, QQIC 125 and one source, 10.0.0.1. + 'v3-query': (message(b'\x11\x64', bytes(4) + bytes.fromhex('027d00010a000001')), 'IGMPv3', 'IGMPv3'), + 'v3-report': (message(b'\x22\x00', bytes.fromhex('00000001') + RECORD), 'IGMPv3', 'IGMPv3'), +} + + +class IGMPChecksumTestCase(unittest.TestCase): + """Imports and helpers every IGMP checksum test shares.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + from pcapkit.corekit.checksum.internet import ChecksumStatus + from pcapkit.protocols.internet.igmp import IGMP + from pcapkit.protocols.internet.igmpv1 import IGMPv1 + from pcapkit.protocols.internet.igmpv2 import IGMPv2 + from pcapkit.protocols.internet.igmpv3 import IGMPv3 + from pcapkit.protocols.internet.ipv4 import IPv4 + from pcapkit.utilities import config + from pcapkit.utilities.warnings import ChecksumWarning, ProtocolWarning + + self.IGMP, self.IPv4 = IGMP, IPv4 + self.classes = {'IGMPv1': IGMPv1, 'IGMPv2': IGMPv2, 'IGMPv3': IGMPv3} + self.Status = ChecksumStatus + self.config = config + self.ProtocolWarning = ProtocolWarning + self.ChecksumWarning = ChecksumWarning + + def parse(self, octets: bytes, cls: type, *, verify: bool = True) -> 'tuple[object, list[tuple[type, str]]]': + """``octets`` parsed as ``cls``, and its ProtocolWarnings as ``(category, message)``.""" + with mock.patch.object(self.config, 'CHECKSUM_VERIFY', verify), \ + warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + layer = cls(octets) + return layer, [(item.category, str(item.message)) + for item in caught if issubclass(item.category, self.ProtocolWarning)] + + def status(self, octets: bytes, **kwargs: object) -> 'tuple[object, list[tuple[type, str]]]': + """The IGMP checksum status of the IPv4 packet ``octets``, and its ProtocolWarnings.""" + layer, messages = self.parse(octets, self.IPv4, **kwargs) + return layer['IGMP'].info.get('checksum_status'), messages + + +class IGMPChecksumReadTests(IGMPChecksumTestCase): + """Reading the checksum of every IGMP version.""" + + def test_a_good_checksum_verifies(self) -> None: + for name, (packet, version, _) in MESSAGES.items(): + with self.subTest(name, via='fixture'): + self.assertEqual(wire.checksum(packet), 0) + cls = self.classes[version] + for via in (cls, self.IGMP): + with self.subTest(name, via=via.__name__): + layer, messages = self.parse(packet, via) + self.assertIs(type(layer), cls) + self.assertEqual((layer.info.checksum_status, messages), (self.Status.VALID, [])) + with self.subTest(name, via='IPv4'): + self.assertEqual(self.status(wire.ipv4(packet, proto=2)), (self.Status.VALID, [])) + + def test_a_bad_checksum_is_recorded_and_warns(self) -> None: + for name, (packet, version, alias) in MESSAGES.items(): + good = int.from_bytes(packet[2:4], 'big') + bad = good ^ 0x0101 + corrupt = with_checksum(packet, bad.to_bytes(2, 'big')) + warning = (self.ChecksumWarning, f'{alias}: checksum {bad:#06x} is wrong; {good:#06x} expected') + with self.subTest(name): + layer, messages = self.parse(corrupt, self.classes[version]) + self.assertEqual((layer.info.checksum_status, messages), (self.Status.INVALID, [warning])) + self.assertEqual(layer.info.checksum, bad.to_bytes(2, 'big')) + with self.subTest(name, via='IPv4'): + self.assertEqual(self.status(wire.ipv4(corrupt, proto=2)), (self.Status.INVALID, [warning])) + + def test_a_zero_checksum_is_wrong_not_absent(self) -> None: + # IGMP lets no sender leave its checksum out (RFC 2236 section 2.3), so zero + # is a wrong checksum, unlike UDP's over IPv4. + for name, (packet, version, _) in MESSAGES.items(): + with self.subTest(name): + layer, messages = self.parse(with_checksum(packet, b'\x00\x00'), self.classes[version]) + self.assertIs(layer.info.checksum_status, self.Status.INVALID) + self.assertEqual([category for category, _ in messages], [self.ChecksumWarning]) + + def test_the_checksum_covers_the_whole_ip_payload(self) -> None: + # Octets past the message are summed too (RFC 2236 section 2.5), IGMPv1's + # included, since RFC 2236 updates RFC 1112. + for name in ('v1-report', 'v2-report', 'v3-report'): + packet, version, alias = MESSAGES[name] + whole = message(packet[:2], packet[4:] + b'XY') + header_only = packet + b'XY' + with self.subTest(name): + self.assertEqual(self.status(wire.ipv4(whole, proto=2)), (self.Status.VALID, [])) + status, messages = self.status(wire.ipv4(header_only, proto=2)) + self.assertIs(status, self.Status.INVALID) + self.assertEqual(messages, [(self.ChecksumWarning, + f'{alias}: checksum 0x{packet[2:4].hex()} is wrong; ' + f'0x{whole[2:4].hex()} expected')]) + + def test_truncated_and_off(self) -> None: + # A Report of IGMPv3 cut short is Raw, its group records unparsed, so the + # IGMPv3 case is a Query. + for name in ('v2-report', 'v3-query'): + packet = message(MESSAGES[name][0][:2], MESSAGES[name][0][4:] + b'XY') + with self.subTest(name): + self.assertEqual(self.status(wire.ipv4(packet, proto=2)[:-1]), (self.Status.TRUNCATED, [])) + corrupt = with_checksum(packet, b'\xbe\xef') + self.assertEqual(self.status(wire.ipv4(corrupt, proto=2), verify=False), (None, [])) + + def test_a_fragment_is_not_verified(self) -> None: + # The first of two fragments, More Fragments set: the checksum covers the + # whole message, which this fragment carries part of. + whole = message(b'\x16\x00', bytes([239, 1, 2, 3]) + bytes(8)) + layer, messages = self.parse(wire.ipv4(whole, mf=True, proto=2), self.IPv4) + self.assertEqual((layer['IGMP'].info.checksum_status, messages), (self.Status.NO_PSEUDO_HEADER, [])) + self.assertEqual(self.IPv4.from_data(layer.info).data, wire.ipv4(whole, mf=True, proto=2)) + + def test_parse_rebuild_is_exact(self) -> None: + # Whatever the status recorded, the checksum is rebuilt as captured. + for name, (packet, version, _) in MESSAGES.items(): + cls = self.classes[version] + cases = ((packet, self.Status.VALID), (with_checksum(packet, b'\xbe\xef'), self.Status.INVALID)) + for octets, status in cases: + for verify in (True, False): + with self.subTest(name, octets=octets.hex(), verify=verify): + layer, _ = self.parse(octets, cls, verify=verify) + self.assertEqual(layer.info.get('checksum_status'), status if verify else None) + self.assertEqual(cls.from_data(layer.info).data, octets) + datagram = wire.ipv4(octets, proto=2) + layer, _ = self.parse(datagram, self.IPv4, verify=verify) + self.assertEqual(self.IPv4.from_data(layer.info).data, datagram) + + +class IGMPChecksumMakeTests(IGMPChecksumTestCase): + """Making the checksum of every IGMP version.""" + + #: ``make()`` arguments of each fixture, the checksum left out. + FIELDS = { + 'v1-query': {'type': 0x11}, + 'v1-report': {'type': 0x12, 'group': '224.0.0.1'}, + 'v2-query': {'type': 0x11, 'max_resp_time': 100}, + 'v2-report': {'type': 0x16, 'group': '239.1.2.3'}, + 'v2-leave': {'type': 0x17, 'group': '239.1.2.3'}, + 'v3-query': {'max_resp_time': 100, 'qrv': 2, 'qqic': 125, 'sources': ['10.0.0.1']}, + 'v3-report': {'type': 0x22, 'records': [{'record_type': 1, 'multicast_address': '239.1.1.1', + 'sources': ['10.0.0.1']}]}, + } + + def test_make_computes_the_checksum(self) -> None: + self.assertEqual(set(self.FIELDS), set(MESSAGES)) + for name, fields in self.FIELDS.items(): + packet, version, _ = MESSAGES[name] + cls = self.classes[version] + with self.subTest(name): + self.assertEqual(cls(**fields).data, packet) + self.assertEqual(self.IGMP(version=int(version[-1]), **fields).data, packet) + layer, messages = self.parse(cls(**fields).data, cls) + self.assertEqual((layer.info.checksum_status, messages), (self.Status.VALID, [])) + # Off, it is left zero; given, it is written as given. + with mock.patch.object(self.config, 'CHECKSUM_COMPUTE', False): + self.assertEqual(cls(**fields).data, with_checksum(packet, b'\x00\x00')) + self.assertEqual(cls(checksum=b'\xbe\xef', **fields).data, with_checksum(packet, b'\xbe\xef')) + + def test_the_computed_checksum_covers_the_payload(self) -> None: + for name in ('v2-report', 'v3-report'): + packet, version, _ = MESSAGES[name] + with self.subTest(name): + made = self.classes[version](payload=b'XY', **self.FIELDS[name]).data + self.assertEqual(made, message(packet[:2], packet[4:] + b'XY')) + + def test_the_ipv4_payload_verifies(self) -> None: + igmp = self.classes['IGMPv2'](**self.FIELDS['v2-report']) + made = self.IPv4(src='10.0.0.1', dst='239.1.2.3', protocol='IGMP', payload=igmp).data + self.assertEqual(made[20:], MESSAGES['v2-report'][0]) + self.assertEqual(self.status(made), (self.Status.VALID, [])) + + @unittest.skipUnless(HAS_SCAPY, 'scapy not installed') + def test_make_agrees_with_scapy(self) -> None: + from scapy.contrib.igmp import IGMP + from scapy.contrib.igmpv3 import IGMPv3 + + for name, fields in self.FIELDS.items(): + version = MESSAGES[name][1] + # Octets past a Query change its version, so only a Report carries some. + payload = b'XY' if 'report' in name else b'' + made = self.classes[version](payload=payload, **fields).data + with self.subTest(name): + packet = (IGMPv3 if version == 'IGMPv3' else IGMP)(made) + del packet.chksum + self.assertEqual(bytes(packet), made) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/internet/test_igmp_unit.py b/tests/protocols/internet/test_igmp_unit.py index 8c3ac09c27..dde406e1ab 100644 --- a/tests/protocols/internet/test_igmp_unit.py +++ b/tests/protocols/internet/test_igmp_unit.py @@ -41,7 +41,8 @@ def internet_checksum(data: 'bytes') -> 'bytes': """Compute the IP-style ones'-complement checksum (:rfc:`1071#section-4.1`). - Fixture builder only: pcapkit does not compute or validate IGMP checksums. + Fixture builder, independent of the sum pcapkit computes and verifies the + IGMP checksum with (#2618). Args: data: Message octets, with the checksum field zeroed. @@ -692,7 +693,8 @@ def test_make_builds_schema(self) -> None: self.assertEqual(schema.type, Type.IGMPv1_Membership_Report) self.assertEqual(schema.max_resp_time, 0) - self.assertEqual(schema.checksum, b'\x00\x00') + # A checksum left out is computed (#2618). + self.assertEqual(schema.checksum, internet_checksum(b'\x12\x00\x00\x00' + bytes(schema)[4:8])) self.assertEqual(bytes(schema)[4:8], ipaddress.ip_address('224.0.0.1').packed) def test_make_rejects_out_of_range_octets(self) -> None: diff --git a/tests/protocols/link/test_gre_checksum_2618_unit.py b/tests/protocols/link/test_gre_checksum_2618_unit.py new file mode 100644 index 0000000000..4241232e5f --- /dev/null +++ b/tests/protocols/link/test_gre_checksum_2618_unit.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +"""GRE verifies and computes its checksum when ``C`` is set. + +GitHub issue #2618: with the Checksum Present bit set, the GRE checksum covers the +header and the payload, with no pseudo-header (:rfc:`2784#section-2.5`). +:meth:`GRE.read ` verifies it under +:data:`~pcapkit.utilities.config.CHECKSUM_VERIFY`, as ICMP's is verified, and records +:attr:`~pcapkit.corekit.checksum.internet.ChecksumStatus.NOT_COMPUTED` when ``C`` is +clear; :meth:`GRE.make ` computes one that +``checksum_present`` asks for without giving it, under +:data:`~pcapkit.utilities.config.CHECKSUM_COMPUTE`. + +No capture under :file:`examples/captures/`, tracked or generated, carries GRE, so the +frames are built here; their checksums are the hand-written sum of +``tests/foundation/_roundtrip.py``, and scapy's GRE layer agrees with what ``make()`` +writes. + +""" +from __future__ import annotations + +import importlib.util +import struct +import unittest +import warnings +from unittest import mock + +from tests._support import reimport_once_per_class +from tests.foundation import _roundtrip as wire + +#: Test tier (#1958): hand-picked parse -> rebuild and make -> parse round trips. +__tier__ = 'ALL' + +HAS_SCAPY = importlib.util.find_spec('scapy') is not None + +#: An IPv4 header (protocol UDP) and an 8-octet UDP header, the payload GRE carries. +IPV4 = bytes.fromhex('4500001c00010000401100000a0000010a000002') + bytes.fromhex('3039003500080000') + + +def gre(payload: bytes, *, checksum: 'int | bool' = True, key: 'int | None' = None) -> bytes: + """A GRE header carrying IPv4 and ``payload``. + + ``checksum`` is the field's value, :data:`True` for the right one, or + :data:`False` to leave ``C`` clear. + + """ + flags = (0x8000 if checksum is not False else 0) | (0x2000 if key is not None else 0) + extra = b'' if key is None else struct.pack('>I', key) + if checksum is False: + return struct.pack('>HH', flags, 0x0800) + extra + payload + head = struct.pack('>HH', flags, 0x0800) + value = wire.checksum(head + b'\x00\x00\x00\x00' + extra + payload) if checksum is True else checksum + return head + struct.pack('>HH', value, 0) + extra + payload + + +def linux_sll(frame: bytes) -> bytes: + """``frame`` behind a ``LINUX_SLL`` header whose ``arphrd_type`` is ``ARPHRD_IPGRE``.""" + return struct.pack('>HHH', 0, 778, 0) + bytes(8) + struct.pack('>H', 0x0800) + frame + + +#: GRE frames with ``C`` set and a right checksum. +FRAMES = { + 'checksum': gre(IPV4), + 'checksum-key': gre(IPV4, key=0x11223344), + 'odd-payload': gre(b'\x01\x02\x03'), +} + + +class GREChecksumTests(unittest.TestCase): + """Reading and making the GRE checksum.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + from pcapkit.corekit.checksum.internet import ChecksumStatus + from pcapkit.protocols.link.gre import GRE + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + from pcapkit.utilities import config + from pcapkit.utilities.warnings import ChecksumWarning, ProtocolWarning + + self.GRE, self.LinuxSLL1 = GRE, LinuxSLL1 + self.Status = ChecksumStatus + self.config = config + self.ProtocolWarning = ProtocolWarning + self.ChecksumWarning = ChecksumWarning + + def parse(self, octets: bytes, cls: 'type | None' = None, *, + verify: bool = True) -> 'tuple[object, list[tuple[type, str]]]': + """``octets`` parsed as ``cls``, GRE by default, and its ProtocolWarnings.""" + with mock.patch.object(self.config, 'CHECKSUM_VERIFY', verify), \ + warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + layer = (cls or self.GRE)(octets) + return layer, [(item.category, str(item.message)) + for item in caught if issubclass(item.category, self.ProtocolWarning)] + + def status(self, octets: bytes, **kwargs: object) -> 'tuple[object, list[tuple[type, str]]]': + """The GRE checksum status of ``octets``, and its ProtocolWarnings.""" + layer, messages = self.parse(octets, **kwargs) + return layer.info.get('checksum_status'), messages + + def test_a_good_checksum_verifies(self) -> None: + for name, frame in FRAMES.items(): + with self.subTest(name): + self.assertEqual(wire.checksum(frame), 0) + self.assertEqual(self.status(frame), (self.Status.VALID, [])) + layer, messages = self.parse(linux_sll(frame), self.LinuxSLL1) + self.assertEqual((layer['GRE'].info.checksum_status, messages), (self.Status.VALID, [])) + + def test_a_bad_checksum_is_recorded_and_warns(self) -> None: + good = int.from_bytes(FRAMES['checksum'][4:6], 'big') + frame = gre(IPV4, checksum=good ^ 0x0101) + warning = (self.ChecksumWarning, f'GRE: checksum {good ^ 0x0101:#06x} is wrong; {good:#06x} expected') + self.assertEqual(self.status(frame), (self.Status.INVALID, [warning])) + layer, messages = self.parse(linux_sll(frame), self.LinuxSLL1) + self.assertEqual((layer['GRE'].info.checksum_status, messages), (self.Status.INVALID, [warning])) + # The payload still parses. + self.assertEqual(layer.protochain.chain, 'LINUX_SLL1:GRE:IPv4:UDP') + + def test_c_clear_and_off(self) -> None: + for frame in (gre(IPV4, checksum=False), gre(IPV4, checksum=False, key=7)): + with self.subTest(frame=frame.hex()): + self.assertEqual(self.status(frame), (self.Status.NOT_COMPUTED, [])) + self.assertEqual(self.status(frame, verify=False), (None, [])) + # Off, a wrong checksum is not looked at either. + bad = gre(IPV4, checksum=0xBEEF) + self.assertEqual(self.status(bad, verify=False), (None, [])) + self.assertIs(self.status(bad)[0], self.Status.INVALID) + + def test_a_fragment_is_not_verified(self) -> None: + # Nothing binds GRE under IP, but a layer an IP fragment carries is not + # verified, whatever it is. + from pcapkit.corekit.checksum.internet import fragment_payload + + with fragment_payload(True): + self.assertEqual(self.status(gre(IPV4, checksum=0xBEEF)), (self.Status.NO_PSEUDO_HEADER, [])) + self.assertEqual(self.status(gre(IPV4, checksum=False)), (self.Status.NOT_COMPUTED, [])) + + def test_parse_rebuild_is_exact(self) -> None: + # Whatever the status recorded, the checksum is rebuilt as captured. + cases = [(frame, self.Status.VALID) for frame in FRAMES.values()] + cases += [(gre(IPV4, checksum=0xBEEF), self.Status.INVALID), + (gre(IPV4, checksum=False), self.Status.NOT_COMPUTED)] + for frame, status in cases: + for verify in (True, False): + with self.subTest(frame=frame.hex(), verify=verify): + layer, _ = self.parse(frame, verify=verify) + self.assertEqual(layer.info.get('checksum_status'), status if verify else None) + self.assertEqual(self.GRE.from_data(layer.info).data, frame) + layer, _ = self.parse(linux_sll(frame), self.LinuxSLL1, verify=verify) + self.assertEqual(self.LinuxSLL1.from_data(layer.info).data, linux_sll(frame)) + + def test_make_computes_the_checksum(self) -> None: + self.assertEqual(self.GRE(checksum_present=True, payload=IPV4).data, FRAMES['checksum']) + self.assertEqual(self.GRE(checksum_present=True, key=0x11223344, payload=IPV4).data, + FRAMES['checksum-key']) + made = self.GRE(checksum_present=True, payload=b'\x01\x02\x03').data + self.assertEqual(made, FRAMES['odd-payload']) + self.assertEqual(self.status(made), (self.Status.VALID, [])) + + def test_off_given_or_absent_is_written_as_is(self) -> None: + with mock.patch.object(self.config, 'CHECKSUM_COMPUTE', False): + self.assertEqual(self.GRE(checksum_present=True, payload=IPV4).data, gre(IPV4, checksum=0)) + self.assertEqual(self.GRE(checksum=0xBEEF, payload=IPV4).data, gre(IPV4, checksum=0xBEEF)) + self.assertEqual(self.GRE(checksum_present=True, checksum=0xBEEF, payload=IPV4).data, + gre(IPV4, checksum=0xBEEF)) + self.assertEqual(self.GRE(payload=IPV4).data, gre(IPV4, checksum=False)) + self.assertEqual(self.GRE(checksum_present=False, payload=IPV4).data, gre(IPV4, checksum=False)) + + def test_make_refuses_a_checksum_with_c_clear(self) -> None: + from pcapkit.utilities.exceptions import ProtocolError + + for kwargs in ({'checksum_present': False, 'checksum': 0}, {'checksum_present': False, 'reserved1': 0}): + with self.subTest(**kwargs), self.assertRaises(ProtocolError): + self.GRE(**kwargs) + # Reserved1 goes with C, whether or not the checksum is given. + self.assertEqual(self.GRE(checksum_present=True, reserved1=5).data[6:8], b'\x00\x05') + + @unittest.skipUnless(HAS_SCAPY, 'scapy not installed') + def test_make_agrees_with_scapy(self) -> None: + from scapy.layers.l2 import GRE + + for kwargs in ({}, {'key': 0x11223344}): + made = self.GRE(checksum_present=True, payload=b'\x01\x02\x03', **kwargs).data + with self.subTest(**kwargs): + packet = GRE(made) + del packet.chksum + self.assertEqual(bytes(packet), made) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/link/test_gre_unit.py b/tests/protocols/link/test_gre_unit.py index bec3ae6ab2..fb665d4c08 100644 --- a/tests/protocols/link/test_gre_unit.py +++ b/tests/protocols/link/test_gre_unit.py @@ -197,7 +197,8 @@ def test_make_infers_presence_from_the_value_given(self) -> None: """As :meth:`L2TPv2.make ` infers ``S`` from whether ``ns``/``nr`` are given, ``C``/``K``/``S`` here are inferred from whether ``checksum``/``key``/``sequence_number`` - are given, not from a separate flag argument. + are given; only ``C`` also has a flag argument, ``checksum_present``, + to ask for a computed checksum (#2618). """ from pcapkit.protocols.link.gre import GRE