From ebc929cd27d0d2111cf365a03f0b384db1a9f20b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 22 Sep 2026 13:43:12 -0400 Subject: [PATCH] fix(hip): PUZZLE and SOLUTION builders must stop inventing wire-format values (#653, #654, #655) `_make_param_puzzle` and `_make_param_solution` derived three wire-format quantities from the payload value rather than taking them from the data model. All three derivations were wrong, in the same two functions, and they are fixed together because the width resolution is one expression that cannot be written twice. * The field width came from `int.bit_length()` and nothing else, so every leading zero octet was dropped on re-serialisation. A SOLUTION read with `Length = 20` rebuilt as `Length = 6`, a PUZZLE read with `Length = 12` as `Length = 5` -- silently, since the integers survive and nothing raises. Both data models now carry `rhash_len`, the field's on-wire width in bits, and both builders prefer it. This only became reachable end to end once #608 was fixed (#629); before that the undersized rebuild tripped the reader's parity guard first and failed loudly. (#653) * SOLUTION's second contents octet is `Reserved`, "zero when sent, ignored when received" (RFC 7401 5.2.5, RFC 5201 5.2.5 identically) -- not a PUZZLE `Lifetime`, which only RFC 7401 5.2.4 defines. pcapkit wrote `0x20`, `0x21`, `0x25` or `0x2b` there and could not write the mandated zero at all: a conformant SOLUTION with `Reserved = 0x00` parsed to `timedelta(0)` and then escaped a bare `ValueError` from `math.log2(0.0)`. `Data_SolutionParameter` and `SolutionParameter` now carry `reserved` verbatim, so the conformant zero round-trips and a received non-zero octet is reproduced rather than re-derived. (#654) * Neither builder read its own `version` keyword, so `version=1` and `version=2` computed identical lengths at every bit width. RFC 5201 5.2.4 and 5.2.5 state both fields as literally 8 bytes and `Length` as literally 12 and 20, and the readers enforce exactly that -- so under HIPv1 each builder accepted only a `bit_length()` of 57..64 and built, for everything else, a parameter this library's own reader rejects. Width now comes from the version under HIPv1. (#655) The remaining `math.log2` sites, both in PUZZLE where a `Lifetime` really lives, now raise `ProtocolError` rather than letting `ValueError` escape. `ProtocolError(BaseError, ValueError)` is what the readers already raise for a malformed PUZZLE or SOLUTION, and deriving from the builtin it replaces keeps any caller written around today's bare `ValueError` working; `EnumError` is `(BaseError, TypeError)` and would silently stop being caught. The same guard covers the upper end, because `UInt8Field` wraps rather than raising -- measured, `300` packs as `0x2c` -- so an out-of-range lifetime would otherwise be written as some other valid-looking duration. A plain `float` lifetime used to escape an `AttributeError` from the `isinstance(lifetime, int)` branch; the test keyed on `timedelta` instead found that. `_make_param_solution` no longer accepts `lifetime=`. `reserved=` and `rhash_len=` are both `None`-sentinelled, so an explicit value overrides what a parsed parameter carries: `Data_SolutionParameter` is immutable, so without that a caller holding a parsed parameter had no way to write the conformant zero over a peer's non-conformant `Reserved`. The plain data fields still let `param` win, as they did before. `rhash_len=` is also the only way a from-scratch HIPv2 build can state a width, which genuinely varies with the Responder's HIT Suite (RFC 7401 2.3, 5.2.10). Five new tests, each verified to fail on 0c7f2b7c9 for the defect's own reason and pass here: `12 != 5` and friends for #655, `expected a positive input` and `'reserved' not found` for #654, `no attribute 'rhash_len'` for #653. The widths exercised are 1, 9, 15, 17, 57 and 65 bits, where the candidate formulas disagree, with the byte-aligned widths kept as controls -- at a multiple of 8 the correct `2 * ceil(b / 8)` coincides with #608's `ceil(b / 4)`, which is why every byte-aligned fixture passed through that defect unharmed. The byte-exact assertions compare the parameter without its trailing padding, and then against the re-packed source schema rather than against a literal, so they pin the `Length` field and the payload octets without encoding a padding rule that #651/#664 is concurrently changing. Verified against a `git merge-tree` of this branch and #664: both library files auto-merge with no conflict, and all five new tests pass against the merged library. Two cases are deliberately accepted rather than rejected, and now say so in `_make_puzzle_field_width`'s docstring: `rhash_len == 0`, which is what the derived path yields for the default `random=0` and what a `Length = 4` parameter parses back to; and a `version` other than 1, which is treated as HIPv2 exactly as both readers' `version == 1` guards do. Coverage holds at 100% statement and branch on all three changed modules, with statements 1385 -> 1414 and branches 316 -> 338; 30 tests / 434 subtests -> 35 / 455. Scoped unit tier green: 291 passed, 1191 subtests. No EXPECTED_FAILURES entry moved -- still 45, with the same four HIP entries. No changelog entry on this branch: that is consolidated in #657. --- pcapkit/protocols/data/internet/hip.py | 28 +- pcapkit/protocols/internet/hip.py | 221 ++++++++-- pcapkit/protocols/schema/internet/hip.py | 10 +- tests/protocols/internet/test_hip_unit.py | 501 +++++++++++++++++++++- 4 files changed, 720 insertions(+), 40 deletions(-) diff --git a/pcapkit/protocols/data/internet/hip.py b/pcapkit/protocols/data/internet/hip.py index 7dc9f4c803..96ae6a5fe3 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 346518aace..1df6734b66 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 d6219c4d92..d76a61d205 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 213f786b0d..4c74e777c5 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()