diff --git a/pcapkit/protocols/data/internet/hip.py b/pcapkit/protocols/data/internet/hip.py index 7dc9f4c80..96ae6a5fe 100644 --- a/pcapkit/protocols/data/internet/hip.py +++ b/pcapkit/protocols/data/internet/hip.py @@ -204,9 +204,16 @@ class PuzzleParameter(Parameter): opaque: 'bytes' #: Random number. random: 'int' + #: On-wire width of :attr:`random`, in bits -- ``RHASH_len`` in + #: :rfc:`7401#section-2.3` terms, which :rfc:`7401#section-5.2.4` makes the + #: exact width of ``Random #I``. Declared rather than recomputed from + #: :meth:`int.bit_length`, which cannot see a leading zero octet: without it + #: a parameter read with ``Length = 12`` re-serialised as ``Length = 5``. + #: See #653. + rhash_len: 'int' if TYPE_CHECKING: - def __init__(self, type: 'Enum_Parameter', critical: 'bool', length: 'int', index: 'int', lifetime: 'timedelta', opaque: 'bytes', random: 'int') -> 'None': ... # pylint: disable=unused-argument,multiple-statements,redefined-builtin,super-init-not-called,line-too-long + def __init__(self, type: 'Enum_Parameter', critical: 'bool', length: 'int', index: 'int', lifetime: 'timedelta', opaque: 'bytes', random: 'int', rhash_len: 'int') -> 'None': ... # pylint: disable=unused-argument,multiple-statements,redefined-builtin,super-init-not-called,line-too-long @info_final @@ -215,17 +222,30 @@ class SolutionParameter(Parameter): #: Numeric index. index: 'int' - #: Lifetime. - lifetime: 'timedelta' + #: Reserved octet -- "zero when sent, ignored when received" + #: (:rfc:`7401#section-5.2.5`, and :rfc:`5201#section-5.2.5` identically). + #: Carried verbatim rather than interpreted, so that re-serialising a parsed + #: parameter reproduces the octet it arrived with. It used to be read as a + #: ``PUZZLE`` ``Lifetime``, which only :rfc:`7401#section-5.2.4` defines, and + #: the conformant ``0x00`` then could not be re-serialised at all. See #654. + reserved: 'int' #: Solution data. opaque: 'bytes' #: Random number. random: 'int' #: Puzzle solution. solution: 'int' + #: On-wire width of :attr:`random` and of :attr:`solution` -- which are equal + #: and each ``RHASH_len / 8`` octets -- in bits. ``RHASH_len`` in + #: :rfc:`7401#section-2.3` terms; :rfc:`7401#section-5.2.5` makes it the exact + #: width of both fields. Declared rather than recomputed from + #: :meth:`int.bit_length`, which cannot see a leading zero octet: without it a + #: parameter read with ``Length = 20`` re-serialised as ``Length = 6``. + #: See #653. + rhash_len: 'int' if TYPE_CHECKING: - def __init__(self, type: 'Enum_Parameter', critical: 'bool', length: 'int', index: 'int', lifetime: 'timedelta', opaque: 'bytes', random: 'int', solution: 'int') -> 'None': ... # pylint: disable=unused-argument,multiple-statements,redefined-builtin,super-init-not-called,line-too-long + def __init__(self, type: 'Enum_Parameter', critical: 'bool', length: 'int', index: 'int', reserved: 'int', opaque: 'bytes', random: 'int', solution: 'int', rhash_len: 'int') -> 'None': ... # pylint: disable=unused-argument,multiple-statements,redefined-builtin,super-init-not-called,line-too-long @info_final diff --git a/pcapkit/protocols/internet/hip.py b/pcapkit/protocols/internet/hip.py index 346518aac..1df6734b6 100644 --- a/pcapkit/protocols/internet/hip.py +++ b/pcapkit/protocols/internet/hip.py @@ -1041,6 +1041,11 @@ def _read_param_puzzle(self, schema: 'Schema_PuzzleParameter', *, version: 'int' lifetime=datetime.timedelta(seconds=2 ** (_time - 32)), opaque=_opak, random=_rand, + # Keep the field's on-wire width, which ``_rand`` alone cannot carry: + # ``int.bit_length()`` sees the value, not the octets it was padded + # into. ``schema.len`` is ``4 + RHASH_len / 8``, so the width in bits + # is ``(schema.len - 4) * 8``. See #653. + rhash_len=(schema.len - 4) * 8, ) return puzzle @@ -1057,7 +1062,7 @@ def _read_param_solution(self, schema: 'Schema_SolutionParameter', *, version: ' +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Type | Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - | #K, 1 byte | Lifetime | Opaque, 2 bytes | + | #K, 1 byte | Reserved | Opaque, 2 bytes | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Random #I, n bytes | / / @@ -1084,7 +1089,13 @@ def _read_param_solution(self, schema: 'Schema_SolutionParameter', *, version: ' raise ProtocolError(f'HIPv{version}: [ParamNo {schema.type}] invalid format') _numk = schema.index - _time = schema.lifetime + # :rfc:`7401#section-5.2.5` names this octet ``Reserved``, "zero when sent, + # ignored when received", and only ``PUZZLE`` (:rfc:`7401#section-5.2.4`) + # has a ``Lifetime`` at this offset. Record it verbatim rather than reading + # it as a ``2^(value - 32)`` duration: interpreting it wrote ``0x20`` into a + # field the RFC requires to be zero, and made the conformant ``0x00`` + # impossible to re-serialise. See #654. + _resv = schema.reserved _opak = schema.opaque _rand = schema.random # ``schema.len`` is ``4 + RHASH_len / 4`` per :rfc:`7401#section-5.2.5`, which @@ -1098,10 +1109,16 @@ def _read_param_solution(self, schema: 'Schema_SolutionParameter', *, version: ' critical=bool(schema.type & 0b1), length=4 + schema.len + (8 - schema.len % 8) % 8, index=_numk, - lifetime=datetime.timedelta(seconds=2 ** (_time - 32)), + reserved=_resv, opaque=_opak, random=_rand, solution=_solt, + # Keep the two fields' shared on-wire width, which the values alone + # cannot carry. Each is ``RHASH_len / 8`` octets and ``schema.len`` is + # ``4 + RHASH_len / 4``, so the width in bits is + # ``((schema.len - 4) // 2) * 8`` -- the same halving the schema's own + # field lengths do. See #653. + rhash_len=((schema.len - 4) // 2) * 8, ) return solution @@ -3122,12 +3139,142 @@ def _make_locator(locator: 'Optional[Data_Locator]' = None, *, locators=locators, ) + @staticmethod + def _make_puzzle_lifetime(code: 'Enum_Parameter', version: 'int', + lifetime: 'timedelta | int | float') -> 'int': + """Encode a ``PUZZLE`` ``Lifetime`` octet. + + :rfc:`7401#section-5.2.4` gives the puzzle lifetime as ``2^(value - 32)`` + seconds -- and it is the *only* place either RFC defines that encoding, so + it applies to ``PUZZLE`` alone. ``SOLUTION``'s octet at the same offset is + ``Reserved`` (:rfc:`7401#section-5.2.5`) and does not come through here. + + Args: + code: parameter code + version: HIP protocol version + lifetime: lifetime, as a :class:`~datetime.timedelta` or a number of + seconds + + Returns: + The ``Lifetime`` octet. + + Raises: + ProtocolError: If ``lifetime`` is not a positive duration, or encodes + to a value the one-octet field cannot hold. + + """ + # Keyed on :class:`~datetime.timedelta`, not on :class:`int`: the old + # ``lifetime if isinstance(lifetime, int) else lifetime.total_seconds()`` + # sent a plain ``float`` down the timedelta branch and escaped an + # :exc:`AttributeError` instead. + seconds = lifetime.total_seconds() if isinstance(lifetime, timedelta) else lifetime + + # ``math.log2`` raises a bare :exc:`ValueError` at zero and below. That is + # not a :class:`~pcapkit.utilities.exceptions.BaseError`, so it escapes the + # library's own error handling with a message naming neither HIP nor the + # field. It is reachable from conformant input rather than only from a + # crafted one: a ``Lifetime`` octet of ``0x00`` means ``2^-32`` seconds, + # below :class:`~datetime.timedelta`'s microsecond resolution, so parsing + # one yields ``timedelta(0)`` and re-serialising it lands here. See #654. + if seconds <= 0: + raise ProtocolError(f'HIPv{version}: [ParamNo {code}] invalid lifetime: ' + f'{seconds} is not a positive number of seconds') + + octet = math.floor(math.log2(seconds) + 32) + + # ``UInt8Field`` wraps rather than raising -- measured, ``300`` packs as + # ``0x2c`` -- so an out-of-range lifetime would otherwise be written as some + # other perfectly valid-looking duration. + if not 0 <= octet <= 0xFF: + raise ProtocolError(f'HIPv{version}: [ParamNo {code}] invalid lifetime: ' + f'{seconds} seconds encodes to {octet}, outside the ' + 'one-octet Lifetime field') + return octet + + @staticmethod + def _make_puzzle_field_width(code: 'Enum_Parameter', version: 'int', + rhash_len: 'Optional[int]', *values: 'int') -> 'int': + """Resolve the on-wire width of a ``PUZZLE``/``SOLUTION`` payload field. + + ``Random #I`` and ``Puzzle solution #J`` are each exactly ``RHASH_len / 8`` + octets wide (:rfc:`7401#section-5.2.4`, :rfc:`7401#section-5.2.5`), where + ``RHASH_len`` is the output length of the Responder's HIT hash algorithm + (:rfc:`7401#section-2.3`). The width is therefore a property of the + association, not of the number that happens to be in the field, and it is + resolved here in that order of authority: + + 1. an explicit ``rhash_len`` argument; + 2. under HIPv1, the constant the RFC fixes -- :rfc:`5201#section-5.2.4` and + :rfc:`5201#section-5.2.5` state both fields as literally 8 bytes and both + ``Length`` values as literally 12 and 20; + 3. the width carried by a parsed parameter, passed in as ``rhash_len`` by + the callers below; + 4. failing all of those, the value's own :meth:`int.bit_length`, rounded up + to whole octets. + + Only case 4 can lose a leading zero octet, and it is the only case where the + width is genuinely unknowable -- a from-scratch HIPv2 build with nothing + declaring it. Reaching for it unconditionally is what re-serialised a + ``Length = 20`` ``SOLUTION`` as ``Length = 6`` (#653) and what built, under + HIPv1, parameters this library's own reader then rejected (#655). + + Two things this deliberately does *not* reject, both of which look like + oversights and are not: + + * ``rhash_len == 0``, i.e. a zero-width payload field. No real hash has a + zero-length output, so no conformant packet carries one -- but it is what + case 4 yields for the default ``random=0``, it is what a ``Length = 4`` + parameter parses back to, and it is what this builder produced before this + change. Rejecting it would turn a degenerate-but-self-consistent case into + a new failure for callers that pass no value at all, which is beyond the + three defects this addresses. + * a ``version`` that is neither 1 nor 2, which falls through to case 4 and is + treated as HIPv2. That mirrors :meth:`_read_param_puzzle` and + :meth:`_read_param_solution`, whose guards are likewise written as + ``version == 1`` rather than as an exhaustive check, so the reader and the + builder agree. Validating the version belongs with :meth:`make`'s public + signature, not here. + + Args: + code: parameter code + version: HIP protocol version + rhash_len: declared field width in bits, if any + *values: the field values that must fit + + Returns: + The width of one field, in octets. + + Raises: + ProtocolError: If the declared width contradicts the protocol version, + is not a whole number of octets, or is too narrow for ``values``. + + """ + bits = max((value.bit_length() for value in values), default=0) + + if version == 1: + if rhash_len is not None and rhash_len != 64: + raise ProtocolError(f'HIPv{version}: [ParamNo {code}] invalid width: ' + f'HIPv1 fixes the field at 64 bits, got {rhash_len}') + rhash_len = 64 + elif rhash_len is None: + rhash_len = 8 * math.ceil(bits / 8) + + if rhash_len < 0 or rhash_len % 8: + raise ProtocolError(f'HIPv{version}: [ParamNo {code}] invalid width: ' + f'RHASH_len must be a non-negative whole number of ' + f'octets, got {rhash_len} bits') + if bits > rhash_len: + raise ProtocolError(f'HIPv{version}: [ParamNo {code}] invalid width: ' + f'a {bits}-bit value does not fit a {rhash_len}-bit field') + return rhash_len // 8 + def _make_param_puzzle(self, code: 'Enum_Parameter', param: 'Optional[Data_PuzzleParameter]' = None, *, # pylint: disable=unused-argument version: 'int', index: 'int' = 0, lifetime: 'timedelta | int' = 0, opaque: 'bytes' = b'', random: 'int' = 0, + rhash_len: 'Optional[int]' = None, **kwargs: 'Any') -> 'Schema_PuzzleParameter': """Make HIP ``PUZZLE`` parameter. @@ -3139,6 +3286,9 @@ def _make_param_puzzle(self, code: 'Enum_Parameter', param: 'Optional[Data_Puzzl lifetime: lifetime opaque: opaque data random: random #I value + rhash_len: on-wire width of ``Random #I``, in bits; defaults to the + width ``param`` was parsed with, or to what ``version`` fixes, or + to ``random``'s own bit length **kwargs: arbitrary keyword arguments Returns: @@ -3147,17 +3297,20 @@ def _make_param_puzzle(self, code: 'Enum_Parameter', param: 'Optional[Data_Puzzl """ if param is not None: index = param.index - lifetime = math.floor(math.log2(param.lifetime.total_seconds()) + 32) + lifetime = self._make_puzzle_lifetime(code, version, param.lifetime) opaque = param.opaque random = param.random + if rhash_len is None: + rhash_len = param.rhash_len else: - lifetime = math.floor(math.log2( - lifetime if isinstance(lifetime, int) else lifetime.total_seconds() - ) + 32) + lifetime = self._make_puzzle_lifetime(code, version, lifetime) return Schema_PuzzleParameter( type=code, - len=4 + math.ceil(random.bit_length() / 8), + # One field of ``RHASH_len / 8`` octets after the 4-octet + # ``#K``/``Lifetime``/``Opaque`` prefix -- :rfc:`7401#section-5.2.4` + # spells the same quantity ``4 + RHASH_len / 8``. + len=4 + self._make_puzzle_field_width(code, version, rhash_len, random), index=index, lifetime=lifetime, opaque=opaque, @@ -3167,10 +3320,11 @@ def _make_param_puzzle(self, code: 'Enum_Parameter', param: 'Optional[Data_Puzzl def _make_param_solution(self, code: 'Enum_Parameter', param: 'Optional[Data_SolutionParameter]' = None, *, # pylint: disable=unused-argument version: 'int', index: 'int' = 0, - lifetime: 'timedelta | int' = 0, + reserved: 'Optional[int]' = None, opaque: 'bytes' = b'', random: 'int' = 0, solution: 'int' = 0, + rhash_len: 'Optional[int]' = None, **kwargs: 'Any') -> 'Schema_SolutionParameter': """Make HIP ``SOLUTION`` parameter. @@ -3179,10 +3333,21 @@ def _make_param_solution(self, code: 'Enum_Parameter', param: 'Optional[Data_Sol param: parameter data version: HIP protocol version index: #K index - lifetime: lifetime + reserved: the ``Reserved`` octet, which :rfc:`7401#section-5.2.5` + requires to be "zero when sent". Defaults to the octet ``param`` + arrived with, so that re-serialising reproduces it rather than + rewriting it, and to that mandated zero otherwise. Pass it + explicitly to override either -- notably to write the conformant + zero over a non-conformant one received from a peer, which is + otherwise unreachable because :class:`Data_SolutionParameter` is + immutable opaque: opaque data random: random #I value solution: solution #J value + rhash_len: on-wire width of ``Random #I`` and ``Puzzle solution #J`` + alike, in bits; defaults to the width ``param`` was parsed with, or + to what ``version`` fixes, or to the values' own bit length + **kwargs: arbitrary keyword arguments Returns: HIP parameter schema. @@ -3190,28 +3355,36 @@ def _make_param_solution(self, code: 'Enum_Parameter', param: 'Optional[Data_Sol """ if param is not None: index = param.index - lifetime = math.floor(math.log2(param.lifetime.total_seconds()) + 32) opaque = param.opaque random = param.random solution = param.solution - else: - lifetime = math.floor(math.log2( - lifetime if isinstance(lifetime, int) else lifetime.total_seconds() - ) + 32) + # Both of these are `None`-sentinelled rather than overwritten + # outright, unlike the data fields above. `Data_SolutionParameter` is + # immutable, so a caller with a parsed parameter in hand has no other + # way to sanitise a peer's non-conformant `Reserved` -- or to re-frame + # the parameter for an association with a different `RHASH_len`. + if reserved is None: + reserved = param.reserved + if rhash_len is None: + rhash_len = param.rhash_len + elif reserved is None: + # :rfc:`7401#section-5.2.5`: "zero when sent". + reserved = 0 return Schema_SolutionParameter( type=code, # Two equal-width fields, ``Random #I`` and ``Puzzle solution #J``, of - # ``RHASH_len / 8`` octets each -- so the contents length is - # ``4 + 2 * ceil(bits / 8)`` and is necessarily even after the 4-octet - # ``#K``/``Reserved``/``Opaque`` prefix. :rfc:`7401#section-5.2.5` spells - # the same quantity ``4 + RHASH_len / 4``, which is an identity only - # because a real ``RHASH_len`` is a whole number of octets; ``ceil(bits - # / 4)`` on an arbitrary :meth:`int.bit_length` is not that quantity and - # yields an odd width that :meth:`_read_param_solution` rejects. See #608. - len=4 + 2 * math.ceil(max(random.bit_length(), solution.bit_length()) / 8), + # ``RHASH_len / 8`` octets each -- so the contents length is necessarily + # even after the 4-octet ``#K``/``Reserved``/``Opaque`` prefix. + # :rfc:`7401#section-5.2.5` spells the same quantity + # ``4 + RHASH_len / 4``, which is an identity only because a real + # ``RHASH_len`` is a whole number of octets; ``ceil(bits / 4)`` on an + # arbitrary :meth:`int.bit_length` is not that quantity and yields an odd + # width that :meth:`_read_param_solution` rejects. See #608. + len=4 + 2 * self._make_puzzle_field_width(code, version, rhash_len, + random, solution), index=index, - lifetime=lifetime, + reserved=reserved, opaque=opaque, random=random, solution=solution, diff --git a/pcapkit/protocols/schema/internet/hip.py b/pcapkit/protocols/schema/internet/hip.py index d6219c4d9..d76a61d20 100644 --- a/pcapkit/protocols/schema/internet/hip.py +++ b/pcapkit/protocols/schema/internet/hip.py @@ -446,8 +446,12 @@ class SolutionParameter(Parameter, code=Enum_Parameter.SOLUTION): #: Numeric index. index: 'int' = UInt8Field() - #: Lifetime. - lifetime: 'int' = UInt8Field() + #: Reserved octet -- "zero when sent, ignored when received" + #: (:rfc:`7401#section-5.2.5`, and :rfc:`5201#section-5.2.5` identically). + #: This octet is *not* a lifetime: only ``PUZZLE`` carries one, at the same + #: offset, and only :rfc:`7401#section-5.2.4` defines the ``2^(value - 32)`` + #: seconds encoding that goes in it. See #654. + reserved: 'int' = UInt8Field() #: Opaque data. opaque: 'bytes' = BytesField(length=2) #: Random data. @@ -458,7 +462,7 @@ class SolutionParameter(Parameter, code=Enum_Parameter.SOLUTION): padding: 'bytes' = PaddingField(length=lambda pkt: (8 - (pkt['len'] % 8)) % 8) if TYPE_CHECKING: - def __init__(self, type: 'Enum_Parameter', len: 'int', index: 'int', lifetime: 'int', + def __init__(self, type: 'Enum_Parameter', len: 'int', index: 'int', reserved: 'int', opaque: 'bytes', random: 'int', solution: 'int') -> 'None': ... diff --git a/tests/protocols/internet/test_hip_unit.py b/tests/protocols/internet/test_hip_unit.py index 213f786b0..4c74e777c 100644 --- a/tests/protocols/internet/test_hip_unit.py +++ b/tests/protocols/internet/test_hip_unit.py @@ -532,14 +532,14 @@ def test_hip_parameter_readers_cover_simple_models_and_guards(self) -> None: ) self.assertEqual(proto._read_param_solution( hip_schema.SolutionParameter(type=Parameter.SOLUTION, len=8, index=1, - lifetime=32, opaque=b'op', random=5, solution=6), + reserved=0, opaque=b'op', random=5, solution=6), version=2, options=options, ).solution, 6) with self.assertRaises(ProtocolError): proto._read_param_solution( hip_schema.SolutionParameter(type=Parameter.SOLUTION, len=9, index=1, - lifetime=32, opaque=b'op', random=5, solution=6), + reserved=0, opaque=b'op', random=5, solution=6), version=2, options=options, ) @@ -845,7 +845,7 @@ def test_hip_parameter_readers_cover_simple_models_and_guards(self) -> None: hip_schema.R1CounterParameter(type=Parameter.R1_COUNTER, len=8, counter=9)), (proto._read_param_solution, hip_schema.SolutionParameter(type=Parameter.SOLUTION, len=8, index=1, - lifetime=32, opaque=b'op', random=5, solution=6), + reserved=0, opaque=b'op', random=5, solution=6), 1), (proto._read_param_seq, hip_schema.SEQParameter(type=Parameter.SEQ, len=3, update_id=11)), @@ -995,7 +995,7 @@ def test_hip_parameter_constructors_cover_keyword_paths(self) -> None: Parameter.SOLUTION, version=2, index=1, - lifetime=datetime.timedelta(seconds=1), + reserved=0, opaque=b'op', random=5, solution=6, @@ -1425,14 +1425,15 @@ def test_hip_parameter_constructors_cover_data_model_and_default_paths(self) -> Parameter.PUZZLE, hip_data.PuzzleParameter(type=Parameter.PUZZLE, critical=False, length=16, index=1, lifetime=dt1, - opaque=b'op', random=5), + opaque=b'op', random=5, rhash_len=64), version=2, ).random, 5) self.assertEqual(proto._make_param_solution( Parameter.SOLUTION, hip_data.SolutionParameter(type=Parameter.SOLUTION, critical=False, - length=24, index=1, lifetime=dt1, - opaque=b'op', random=5, solution=6), + length=24, index=1, reserved=0, + opaque=b'op', random=5, solution=6, + rhash_len=64), version=2, ).solution, 6) self.assertEqual(proto._make_param_seq( @@ -2455,7 +2456,7 @@ def test_hip_solution_parameter_length_is_two_whole_octet_fields(self) -> None: self.assertEqual(expected, 4 + 2 * math.ceil(bits / 8)) schema = proto._make_param_solution( - Parameter.SOLUTION, version=2, index=1, lifetime=2, + Parameter.SOLUTION, version=2, index=1, reserved=0, opaque=b'op', random=random, solution=solution, ) self.assertEqual(schema.len, expected) @@ -2504,7 +2505,7 @@ def test_hip_solution_parameter_at_57_bits_stays_legal_for_hipv1(self) -> None: self.assertEqual(random.bit_length(), 57) schema = proto._make_param_solution( - Parameter.SOLUTION, version=1, index=1, lifetime=2, + Parameter.SOLUTION, version=1, index=1, reserved=0, opaque=b'op', random=random, solution=solution, ) self.assertEqual(schema.len, 20) @@ -2513,6 +2514,488 @@ def test_hip_solution_parameter_at_57_bits_stays_legal_for_hipv1(self) -> None: self.assertEqual(parsed.random, random) self.assertEqual(parsed.solution, solution) + def test_hip_puzzle_and_solution_keep_the_on_wire_field_width(self) -> None: + """#653: re-serialising a parsed ``PUZZLE`` or ``SOLUTION`` must reproduce + the field width it arrived with, leading zero octets included. + + The width of ``Random #I`` -- and, for ``SOLUTION``, of ``Puzzle solution + #J`` -- is ``RHASH_len / 8`` octets (:rfc:`7401#section-5.2.4`, + :rfc:`7401#section-5.2.5`), a property of the Responder's HIT Suite rather + than of the number that happens to sit in the field. Both builders derived + it from :meth:`int.bit_length` instead, and the data model carried nothing + better to derive it from, so every leading zero octet was dropped on the way + out: measured on ``origin/main`` at ``0c7f2b7c9``, a ``SOLUTION`` read with + ``Length = 20`` re-serialised as ``Length = 6`` and a ``PUZZLE`` read with + ``Length = 12`` as ``Length = 5``. + + What makes this worth a test of its own rather than an + ``EXPECTED_FAILURES`` entry is that the cycle *closes*: nothing raises, the + integers survive, and the round trip silently yields a parameter describing + a different puzzle -- a conformant peer reads ``RHASH_len = 8`` bits where + the sender said 64. It only became reachable end to end once #608 was fixed + (#629); before that the undersized rebuild tripped + :meth:`~pcapkit.protocols.internet.hip.HIP._read_param_solution`'s parity + guard first and failed loudly. + + The octets below are written by hand, not by this library's own builder, + which is the only way to present it with a value narrower than its field. + Every HIP fixture is generated by the code under test, and the generator + passes ``random`` and ``solution`` as ``0`` -- the one value that has no + width to lose -- which is why no fixture could ever have caught this. + + """ + from pcapkit.const.hip.parameter import Parameter + from pcapkit.corekit.multidict import OrderedMultiDict + from pcapkit.protocols.internet.hip import HIP + from pcapkit.protocols.schema.internet import hip as hip_schema + + proto = object.__new__(HIP) + options = OrderedMultiDict() + + # SOLUTION: Type 321, Length 20, #K = 1, Reserved = 0x20, Opaque = b'op', + # then Random #I and Puzzle solution #J as 8 octets each carrying 1. + # + # Reserved is 0x20 rather than the conformant 0x00 *only* so that this case + # isolates #653 on the unfixed tree: a 0x00 there trips #654's + # ``math.log2(0)`` first and the width loss is never reached. #654's own + # test below uses the conformant 0x00. + wire = bytes.fromhex('0141' '0014' '01' '20' '6f70' + '0000000000000001' '0000000000000001' '00000000') + self.assertEqual(len(wire), 28) + + unpacked = hip_schema.SolutionParameter.unpack(wire) + self.assertEqual(unpacked.len, 20) + self.assertEqual(unpacked.random, 1) + self.assertEqual(unpacked.solution, 1) + + parsed = proto._read_param_solution(unpacked, version=2, options=options) + self.assertEqual(parsed.random, 1) + self.assertEqual(parsed.solution, 1) + # The width is declared on the data model, not inferred from the values. + self.assertEqual(parsed.rhash_len, 64) + + rebuilt = proto._make_param_solution(Parameter.SOLUTION, parsed, version=2) + self.assertEqual(rebuilt.len, 20) + self.assertNotEqual(rebuilt.len, 6) # what the unfixed builder produced + # Compared without the trailing padding, and then against the re-packed + # source schema rather than against the literal. Both are deliberate: the + # padding rule is itself in flight (#651/#664), and this test is about the + # `Length` field and the payload octets, not about how many alignment octets + # follow them. Either assertion alone would be weaker -- the first pins the + # octets that came off the wire, the second pins losslessness end to end. + self.assertEqual(bytes(rebuilt)[:4 + rebuilt.len], wire[:4 + unpacked.len]) + self.assertEqual(bytes(rebuilt), bytes(unpacked)) + + # PUZZLE: Type 257, Length 12, #K = 1, Lifetime = 0x20, Opaque = b'op', + # then Random #I as 8 octets carrying 1. + wire = bytes.fromhex('0101' '000c' '01' '20' '6f70' + '0000000000000001' '00000000') + self.assertEqual(len(wire), 20) + + unpacked = hip_schema.PuzzleParameter.unpack(wire) + self.assertEqual(unpacked.len, 12) + self.assertEqual(unpacked.random, 1) + + parsed = proto._read_param_puzzle(unpacked, version=2, options=options) + self.assertEqual(parsed.random, 1) + self.assertEqual(parsed.rhash_len, 64) + + rebuilt = proto._make_param_puzzle(Parameter.PUZZLE, parsed, version=2) + self.assertEqual(rebuilt.len, 12) + self.assertNotEqual(rebuilt.len, 5) # what the unfixed builder produced + self.assertEqual(bytes(rebuilt)[:4 + rebuilt.len], wire[:4 + unpacked.len]) + self.assertEqual(bytes(rebuilt), bytes(unpacked)) + + def test_hip_solution_second_octet_is_reserved_not_a_lifetime(self) -> None: + """#654: ``SOLUTION``'s second contents octet is ``Reserved``, and must be + zero when sent. + + :rfc:`7401#section-5.2.5` names it ``Reserved`` -- "zero when sent, ignored + when received" -- and :rfc:`5201#section-5.2.5` says the same, so there is + no HIP version under which it is a duration. Only ``PUZZLE`` + (:rfc:`7401#section-5.2.4`) has a ``Lifetime`` at that offset, and only + ยง5.2.4 defines the ``2^(value - 32)`` seconds encoding that pcapkit was + applying to both. Measured on ``origin/main`` at ``0c7f2b7c9``, the octet + came out as ``0x20``, ``0x21``, ``0x25`` or ``0x2b`` depending on the + lifetime asked for, and the one value the RFC actually permits -- zero -- + could not be written at all. + + Three things are asserted, in the order they matter. The octet is zero for a + from-scratch build, whatever else is passed. A parameter that arrives with + the conformant ``0x00`` survives the round trip, where it used to be + unbuildable. And a parameter that arrives with a non-zero ``Reserved`` + re-emits that same octet rather than a re-derived one, because round-trip + fidelity is what #653 is about and "ignored when received" is honoured by + not interpreting the value, not by discarding it. + + """ + from pcapkit.const.hip.parameter import Parameter + from pcapkit.corekit.multidict import OrderedMultiDict + from pcapkit.protocols.data.internet import hip as hip_data + from pcapkit.protocols.internet.hip import HIP + from pcapkit.protocols.schema.internet import hip as hip_schema + from pcapkit.utilities.exceptions import UnsupportedCall + + proto = object.__new__(HIP) + options = OrderedMultiDict() + + # The data model no longer claims SOLUTION has a lifetime. + self.assertIn('reserved', hip_data.SolutionParameter.__annotations__) + self.assertNotIn('lifetime', hip_data.SolutionParameter.__annotations__) + self.assertIn('reserved', hip_schema.SolutionParameter.__annotations__) + self.assertNotIn('lifetime', hip_schema.SolutionParameter.__annotations__) + # ... while PUZZLE, where the RFC does put one, still does. + self.assertIn('lifetime', hip_data.PuzzleParameter.__annotations__) + + # Zero when sent, for a from-scratch build, at the default and explicitly. + for reserved in (None, 0): + with self.subTest(reserved=reserved): + kwargs = {} if reserved is None else {'reserved': reserved} + schema = proto._make_param_solution( + Parameter.SOLUTION, version=2, index=1, opaque=b'op', + random=1 << 63, solution=1 << 63, **kwargs, + ) + self.assertEqual(schema.reserved, 0) + self.assertEqual(schema.len, 20) + # Octet 5 of the parameter is the Reserved position: two octets of + # Type, two of Length, one of #K, then Reserved. + self.assertEqual(bytes(schema)[5], 0x00) + for stale in (0x20, 0x21, 0x25, 0x2b): + self.assertNotEqual(bytes(schema)[5], stale) + + # A conformant SOLUTION -- Reserved = 0x00 -- parsed and re-serialised. On + # the unfixed tree this raised a bare ValueError from ``math.log2(0.0)``, + # because 0x00 was read as ``2 ** (0 - 32)`` seconds, which is below + # timedelta's microsecond resolution and rounds to timedelta(0). + # Compared against the re-packed source schema rather than against the + # literal, and with the trailing padding excluded, for the reason given in + # `test_hip_puzzle_and_solution_keep_the_on_wire_field_width`: the padding + # rule is in flight (#651/#664) and is not what this test is about. + conformant = bytes.fromhex('0141' '0014' '01' '00' '6f70' + '8000000000000000' '8000000000000000' '00000000') + conformant_schema = hip_schema.SolutionParameter.unpack(conformant) + parsed = proto._read_param_solution(conformant_schema, version=2, options=options) + self.assertEqual(parsed.reserved, 0) + rebuilt = proto._make_param_solution(Parameter.SOLUTION, parsed, version=2) + self.assertEqual(rebuilt.reserved, 0) + self.assertEqual(bytes(rebuilt)[:4 + rebuilt.len], conformant[:4 + rebuilt.len]) + self.assertEqual(bytes(rebuilt), bytes(conformant_schema)) + + # A non-zero Reserved is carried verbatim rather than re-derived. + received = bytes.fromhex('0141' '0014' '01' '2b' '6f70' + '8000000000000000' '8000000000000000' '00000000') + received_schema = hip_schema.SolutionParameter.unpack(received) + parsed = proto._read_param_solution(received_schema, version=2, options=options) + self.assertEqual(parsed.reserved, 0x2b) + rebuilt = proto._make_param_solution(Parameter.SOLUTION, parsed, version=2) + self.assertEqual(bytes(rebuilt)[5], 0x2b) + self.assertEqual(bytes(rebuilt)[:4 + rebuilt.len], received[:4 + rebuilt.len]) + self.assertEqual(bytes(rebuilt), bytes(received_schema)) + + # ... but an explicit `reserved` still overrides it, which is the only way to + # write the conformant zero over a peer's non-conformant octet: the data + # model is immutable, so the parsed object cannot be corrected in place. + with self.assertRaises(UnsupportedCall): + parsed.reserved = 0 # type: ignore[misc] + sanitised = proto._make_param_solution( + Parameter.SOLUTION, parsed, version=2, reserved=0, + ) + self.assertEqual(sanitised.reserved, 0) + self.assertEqual(bytes(sanitised)[5], 0x00) + # `received` and `conformant` differ in that octet alone, so zeroing it + # reproduces the conformant parameter exactly. + self.assertEqual(bytes(sanitised), bytes(conformant_schema)) + + def test_hip_puzzle_lifetime_guard_raises_an_in_library_error(self) -> None: + """#654: a lifetime ``math.log2`` cannot encode must raise a pcapkit + exception, not a bare :exc:`ValueError`. + + :mod:`pcapkit.utilities.exceptions` exists so that only user-facing stack + information reaches the user: raising a + :class:`~pcapkit.utilities.exceptions.BaseError` logs once at + ``CRITICAL`` and, outside development mode, trims the traceback. A bare + :exc:`ValueError` from ``math.log2`` gets none of that -- it is invisible to + ``except BaseError``, it is not logged, and its message, ``expected a + positive input``, names neither HIP nor the parameter nor the field. + + :class:`~pcapkit.utilities.exceptions.ProtocolError` is the right member of + that family rather than the nearest-named one. It is already what both + readers raise for a malformed ``PUZZLE`` or ``SOLUTION``, and it is declared + ``ProtocolError(BaseError, ValueError)`` -- so it joins the family *and* + stays catchable by any caller already written around the + :exc:`ValueError` that escapes today. Both halves are asserted below, + because the second is what makes this a non-breaking change. + :class:`~pcapkit.utilities.exceptions.EnumError` would not do: it is + ``EnumError(BaseError, TypeError)``, so it would silently stop being caught. + + Reachable from conformant input rather than only from a crafted one: a + ``Lifetime`` octet of ``0x00`` is a legal encoding of ``2^-32`` seconds, + which :class:`~datetime.timedelta` rounds to zero, so parsing an ordinary + PUZZLE and re-emitting it lands here. + + """ + from pcapkit.const.hip.parameter import Parameter + from pcapkit.corekit.multidict import OrderedMultiDict + from pcapkit.protocols.internet.hip import HIP + from pcapkit.protocols.schema.internet import hip as hip_schema + from pcapkit.utilities.exceptions import BaseError, ProtocolError + + proto = object.__new__(HIP) + options = OrderedMultiDict() + + self.assertTrue(issubclass(ProtocolError, BaseError)) + self.assertTrue(issubclass(ProtocolError, ValueError)) + + # Non-positive lifetimes, as an int and as a timedelta. ``0`` is the + # builder's own default, which is what the round-trip generator overrides + # with ``{'lifetime': 1}`` to dodge -- an override that also left `random` + # at 0 and so hid #608 for as long as it existed. + for lifetime in (0, 0.0, datetime.timedelta(0), -1, datetime.timedelta(seconds=-1)): + with self.subTest(lifetime=lifetime): + with self.assertRaises(ProtocolError) as caught: + proto._make_param_puzzle( + Parameter.PUZZLE, version=2, index=1, lifetime=lifetime, + opaque=b'op', random=1 << 63, + ) + self.assertIsInstance(caught.exception, BaseError) + self.assertIsInstance(caught.exception, ValueError) + self.assertIn('invalid lifetime', str(caught.exception)) + self.assertIn('257', str(caught.exception)) + + # A lifetime too large for the one-octet field. ``UInt8Field`` wraps rather + # than raising -- measured, 300 packs as 0x2c -- so without this guard the + # parameter would carry some other, valid-looking duration. + with self.assertRaises(ProtocolError) as caught: + proto._make_param_puzzle( + Parameter.PUZZLE, version=2, index=1, lifetime=1 << 240, + opaque=b'op', random=1 << 63, + ) + self.assertIn('invalid lifetime', str(caught.exception)) + + # A PUZZLE whose Lifetime octet is 0x00, parsed then re-serialised: the + # conformant-input path. On the unfixed tree this was a bare ValueError. + wire = bytes.fromhex('0101' '000c' '01' '00' '6f70' + '8000000000000000' '00000000') + parsed = proto._read_param_puzzle( + hip_schema.PuzzleParameter.unpack(wire), version=2, options=options, + ) + self.assertEqual(parsed.lifetime, datetime.timedelta(0)) + with self.assertRaises(ProtocolError) as caught: + proto._make_param_puzzle(Parameter.PUZZLE, parsed, version=2) + self.assertIsInstance(caught.exception, BaseError) + + # A positive lifetime still encodes exactly as it did before. + self.assertEqual(proto._make_param_puzzle( + Parameter.PUZZLE, version=2, index=1, lifetime=1, + opaque=b'op', random=1 << 63, + ).lifetime, 32) + + def test_hip_puzzle_and_solution_size_from_version_under_hipv1(self) -> None: + """#655: under HIPv1 both builders must size from ``version``, not from the + value's bit length. + + :rfc:`5201#section-5.2.4` and :rfc:`5201#section-5.2.5` state the widths as + literal constants -- ``Random #I`` and ``Puzzle solution #J`` are 8 bytes + each, ``Length`` is 12 for ``PUZZLE`` and 20 for ``SOLUTION`` -- and + "Random #I is represented as a 64-bit integer" leaves no narrower reading. + Both builders took a ``version`` keyword and neither read it, so measured on + ``origin/main`` at ``0c7f2b7c9`` the ``version=1`` and ``version=2`` lengths + were **identical at every bit width**, and under HIPv1 each builder accepted + only values whose ``bit_length()`` landed in 57..64. Everything narrower + built a parameter this library's own reader rejects -- the same shape as + #608 -- and everything wider overshot. + + On the widths chosen + -------------------- + Multiples of 8 cannot discriminate: at 8, 16, 32, 56, 64 and 128 bits the + correct ``2 * ceil(b / 8)`` agrees with #608's ``ceil(b / 4)`` and with the + floor variant, which is exactly why every byte-aligned fixture passed + through that defect unharmed. So 1, 9, 15, 17, 57 and 65 are the ones + carrying the weight here, and the byte-aligned rows are kept as controls. + 57 and 65 bracket HIPv1's field: 57 is the narrowest value whose derived + width reached the required 20, and 65 the narrowest that overshoots it. + + """ + from pcapkit.const.hip.parameter import Parameter + from pcapkit.corekit.multidict import OrderedMultiDict + from pcapkit.protocols.internet.hip import HIP + from pcapkit.utilities.exceptions import ProtocolError + + proto = object.__new__(HIP) + options = OrderedMultiDict() + + # bits, SOLUTION len v2, PUZZLE len v2 -- HIPv1 is always 20 and 12. + cases = ( + (1, 6, 5), + (8, 6, 5), + (9, 8, 6), + (15, 8, 6), + (16, 8, 6), + (17, 10, 7), + (32, 12, 8), + (56, 18, 11), + (57, 20, 12), + (64, 20, 12), + ) + for bits, solution_v2, puzzle_v2 in cases: + value = 1 << (bits - 1) + with self.subTest(bits=bits): + self.assertEqual(value.bit_length(), bits) + + # HIPv1: the RFC's constants, at every width, narrow values + # included. PUZZLE goes first deliberately -- its keyword arguments + # are the same before and after this change, so on the unfixed tree + # this assertion is reached and reports #655 directly (measured: + # ``AssertionError: 12 != 5`` at 1 bit). SOLUTION's cannot be, since + # there the unfixed builder crashes on its own ``lifetime`` default + # before any length is computed -- which is #654. + schema = proto._make_param_puzzle( + Parameter.PUZZLE, version=1, index=1, lifetime=1, + opaque=b'op', random=value, + ) + self.assertEqual(schema.len, 12) + # ... and the reader, which enforces `len == 12`, accepts it. + self.assertEqual(proto._read_param_puzzle( + schema, version=1, options=options, + ).random, value) + + schema = proto._make_param_solution( + Parameter.SOLUTION, version=1, index=1, reserved=0, + opaque=b'op', random=value, solution=value, + ) + self.assertEqual(schema.len, 20) + self.assertEqual(proto._read_param_solution( + schema, version=1, options=options, + ).random, value) + + # HIPv2: unchanged, still derived from the value when nothing + # declares the width. This is what makes the v1 column a fix rather + # than a blanket constant. + self.assertEqual(proto._make_param_puzzle( + Parameter.PUZZLE, version=2, index=1, lifetime=1, + opaque=b'op', random=value, + ).len, puzzle_v2) + self.assertEqual(proto._make_param_solution( + Parameter.SOLUTION, version=2, index=1, reserved=0, + opaque=b'op', random=value, solution=value, + ).len, solution_v2) + + # The two versions agree only where the derived width happens to be + # the RFC's constant, which is the whole of the defect: they used to + # agree everywhere. + self.assertEqual(solution_v2 == 20, bits in (57, 64)) + self.assertEqual(puzzle_v2 == 12, bits in (57, 64)) + + # A value HIPv1 cannot represent is refused outright, with a pcapkit + # exception, rather than built into a parameter the reader will reject. + for bits in (65, 128): + value = 1 << (bits - 1) + with self.subTest(bits=bits): + with self.assertRaises(ProtocolError): + proto._make_param_puzzle( + Parameter.PUZZLE, version=1, index=1, lifetime=1, + opaque=b'op', random=value, + ) + with self.assertRaises(ProtocolError): + proto._make_param_solution( + Parameter.SOLUTION, version=1, index=1, reserved=0, + opaque=b'op', random=value, solution=value, + ) + + def test_hip_puzzle_and_solution_accept_an_explicit_field_width(self) -> None: + """#653/#655: ``rhash_len`` declares the field width for a from-scratch + build, which is the only way HIPv2 can express one. + + Under HIPv2 the width is ``RHASH_len / 8`` octets, where ``RHASH_len`` is + the output length of the Responder's HIT hash algorithm + (:rfc:`7401#section-2.3`). It genuinely varies -- ``RSA,DSA/SHA-256`` is the + REQUIRED HIT Suite (:rfc:`7401#section-5.2.10`), giving a 256-bit + ``RHASH_len`` and a 32-octet field -- so no constant and no version can + supply it, and a caller building a full-width parameter around a small value + has nowhere else to say so. + + """ + from pcapkit.const.hip.parameter import Parameter + from pcapkit.corekit.multidict import OrderedMultiDict + from pcapkit.protocols.internet.hip import HIP + from pcapkit.utilities.exceptions import ProtocolError + + proto = object.__new__(HIP) + options = OrderedMultiDict() + + # RHASH_len = 256 bits, the REQUIRED HIT Suite's hash: 32-octet fields, so + # Length is 4 + 2 * 32 = 68 for SOLUTION and 4 + 32 = 36 for PUZZLE -- even + # though the value would otherwise derive a 1-octet field. + schema = proto._make_param_solution( + Parameter.SOLUTION, version=2, index=1, reserved=0, opaque=b'op', + random=1, solution=1, rhash_len=256, + ) + self.assertEqual(schema.len, 68) + self.assertEqual(proto._read_param_solution( + schema, version=2, options=options, + ).rhash_len, 256) + + schema = proto._make_param_puzzle( + Parameter.PUZZLE, version=2, index=1, lifetime=1, opaque=b'op', + random=1, rhash_len=256, + ) + self.assertEqual(schema.len, 36) + self.assertEqual(proto._read_param_puzzle( + schema, version=2, options=options, + ).rhash_len, 256) + + # A width that is not a whole number of octets, or is negative, is not an + # RHASH_len -- the natural output length of a hash function, in bits. + for rhash_len in (12, -8): + with self.subTest(rhash_len=rhash_len): + with self.assertRaises(ProtocolError): + proto._make_param_puzzle( + Parameter.PUZZLE, version=2, index=1, lifetime=1, opaque=b'op', + random=1, rhash_len=rhash_len, + ) + + # Nor is one too narrow for the value it has to hold: silently truncating + # is what an undersized `len` used to do. + with self.assertRaises(ProtocolError): + proto._make_param_solution( + Parameter.SOLUTION, version=2, index=1, reserved=0, opaque=b'op', + random=1 << 64, solution=1, rhash_len=64, + ) + + # Under HIPv1 the RFC's constant is not negotiable. + with self.assertRaises(ProtocolError): + proto._make_param_solution( + Parameter.SOLUTION, version=1, index=1, reserved=0, opaque=b'op', + random=1, solution=1, rhash_len=256, + ) + + # An explicit width overrides the one a parsed parameter carries, so a + # parameter can be re-emitted for a different association. + parsed = proto._read_param_solution( + proto._make_param_solution( + Parameter.SOLUTION, version=2, index=1, reserved=0, opaque=b'op', + random=1, solution=1, rhash_len=64, + ), + version=2, options=options, + ) + self.assertEqual(parsed.rhash_len, 64) + self.assertEqual(proto._make_param_solution( + Parameter.SOLUTION, parsed, version=2, rhash_len=128, + ).len, 36) + # ... and the same for PUZZLE, whose ``param``-plus-explicit-width branch is + # otherwise never taken. + parsed = proto._read_param_puzzle( + proto._make_param_puzzle( + Parameter.PUZZLE, version=2, index=1, lifetime=1, opaque=b'op', + random=1, rhash_len=64, + ), + version=2, options=options, + ) + self.assertEqual(parsed.rhash_len, 64) + self.assertEqual(proto._make_param_puzzle( + Parameter.PUZZLE, parsed, version=2, rhash_len=128, + ).len, 20) + if __name__ == '__main__': unittest.main()