Skip to content

SOLUTION's Reserved octet is written as a PUZZLE Lifetime, and lifetime=0 escapes a bare ValueError from math.log2 #654

Description

@JarryShaw

RFC 7401 §5.2.5 names the SOLUTION parameter's second contents octet Reserved, "zero when sent, ignored when received". SolutionParameter names that octet lifetime and both sides treat it as PUZZLE's Lifetime — a duration encoded as 2^(value - 32) seconds. So pcapkit writes 0x20, 0x21 or 0x25 into a field the RFC says MUST be zero, and it cannot write the zero at all: asking for it reaches math.log2(0) and escapes a bare ValueError.

Those two halves are one causal chain rather than two coincidences, which is why they are filed together. The RFC-mandated value for this octet is zero; zero is the one value the builder cannot encode; and a conformant peer's SOLUTION — whose Reserved is correctly 0x00 — parses to timedelta(0) and then cannot be re-serialised at all.

The RFC text, fetched

Fetched from https://www.rfc-editor.org/rfc/rfc7401.txt (sha256 09366b9f83dc80593172304ffcf05f57afd186aacb468ac6170a8fe26944c4be). SOLUTION is §5.2.5, not §5.2.4 — §5.2.4 is PUZZLE. Verbatim, RFC 7401 §5.2.5:

     0                   1                   2                   3
     0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |             Type              |             Length            |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |  #K, 1 byte   |   Reserved    |        Opaque, 2 bytes        |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |                      Random #I, n bytes                       |
    /                                                               /
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |            Puzzle solution #J, RHASH_len / 8 bytes            |
    /                                                               /
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

    Type                321
    Length              4 + RHASH_len / 4
    #K                  #K is the number of verified bits
    Reserved            zero when sent, ignored when received
    Opaque              copied unmodified from the received PUZZLE
                        parameter
    Random #I           random number of size RHASH_len bits
    Puzzle solution #J  random number of size RHASH_len bits

and for contrast, PUZZLE at §5.2.4, where a lifetime genuinely lives:

    |  #K, 1 byte   |    Lifetime   |        Opaque, 2 bytes        |
    ...
    Lifetime       puzzle lifetime 2^(value - 32) seconds

The 2^(value - 32) encoding pcapkit applies to both is PUZZLE's, and §5.2.4 is the only place the RFC defines it.

HIPv1 agrees, so this is not a version-specific reading. RFC 5201 (fetched from https://www.rfc-editor.org/rfc/rfc5201.txt, sha256 8b42d181a8e239713eb8d608c11e8e75829561d994adbd3caa96c6db6e69cef6), §5.2.5:

      | K, 1 byte     |   Reserved    |        Opaque, 2 bytes        |
      ...
      Reserved           zero when sent, ignored when received

against its own §5.2.4, where PUZZLE has | K, 1 byte | Lifetime | and Lifetime puzzle lifetime 2^(value-32) seconds. Both HIP versions name SOLUTION's second octet Reserved and require it to be zero, and pcapkit uses one SolutionParameter schema for both, so there is no version under which the current field name is right.

The sites

pcapkit/protocols/schema/internet/hip.py:450, in the SolutionParameter declared at :444, gives the octet a lifetime field where the RFC has Reserved:

    #: Numeric index.
    index: 'int' = UInt8Field()
    #: Lifetime.
    lifetime: 'int' = UInt8Field()

Both sides then convert it as a duration. pcapkit/protocols/internet/hip.py:1101 on the way in (and :1041 for PUZZLE, where it is correct):

lifetime=datetime.timedelta(seconds=2 ** (_time - 32)),

and on the way out, four math.log2 call sites across the two builders — pcapkit/protocols/internet/hip.py:3150, :3154, :3193 and :3198:

lifetime = math.floor(math.log2(param.lifetime.total_seconds()) + 32)     # :3150, :3193
lifetime = math.floor(math.log2(                                          # :3154, :3198
    lifetime if isinstance(lifetime, int) else lifetime.total_seconds()
) + 32)

Two of those four are in _make_param_puzzle, where Lifetime is a real field, so the bare-exception half of this is not SOLUTION-specific — it is shared by both builders. Only the misnamed-field half is specific to SOLUTION.

Measured

On origin/main (a62aed134), CPython 3.14.7, importing from an immutable git archive snapshot with pcapkit.__file__ asserted against it:

MEASURED pcapkit.__file__ = /tmp/hipissues/tA62/pcapkit/__init__.py
CPython 3.14.7

SOLUTION: the octet written at the Reserved position (RFC 7401 5.2.5)
  lifetime=0     -> ValueError: expected a positive input
  lifetime=1     -> schema.lifetime=0x20  wire octet[5]=0x20  len=20
  lifetime=2     -> schema.lifetime=0x21  wire octet[5]=0x21  len=20
  lifetime=32    -> schema.lifetime=0x25  wire octet[5]=0x25  len=20
  lifetime=3600  -> schema.lifetime=0x2b  wire octet[5]=0x2b  len=20

Octet 5 of the parameter is the Reserved position. 0x20, 0x21, 0x25 and 0x2b are all non-zero, and the RFC says the field is zero when sent. The default — lifetime: 'timedelta | int' = 0, at pcapkit/protocols/internet/hip.py:3170 for SOLUTION and :3128 for PUZZLE — is the one input that would produce the conformant 0x00, and it raises instead.

The exception, with the raise site (the traceback needs an explicit limit, because pcapkit sets sys.tracebacklimit = 0 when a BaseError is raised, which silently truncates an ordinary extract_tb to nothing):

  _make_param_solution(lifetime=0) -> ValueError: expected a positive input
       isinstance(exc, BaseError) = False
       pcapkit/protocols/internet/hip.py:3198 in _make_param_solution   |   lifetime = math.floor(math.log2(
  _make_param_puzzle(lifetime=0) -> ValueError: expected a positive input
       isinstance(exc, BaseError) = False
       pcapkit/protocols/internet/hip.py:3154 in _make_param_puzzle   |   lifetime = math.floor(math.log2(

ValueError: expected a positive input is CPython 3.14's math.log2(0) message — confirmed by calling math.log2(0) directly in the same interpreter.

A conformant SOLUTION cannot be re-serialised at all

This is the part that makes it more than a bad default. A parameter whose Reserved octet is 0x00 — the only value the RFC permits — round-trips into an unbuildable object:

conformant-Reserved SOLUTION on the wire (28 octets):
  01 41  00 14  01  00  6f 70  80 00 00 00 00 00 00 00  80 00 00 00 00 00 00 00  00 00 00 00
  type   len=20 #K  ^^  opaque Random #I                solution #J              padding
                    Reserved = 0x00, as RFC 7401 5.2.5 and RFC 5201 5.2.5 both require
parsed  -> lifetime = datetime.timedelta(0)
rebuild with param=<that parsed object>:
  ValueError: expected a positive input, got 0.0
  isinstance(exc, pcapkit.utilities.exceptions.BaseError) = False

2 ** (0 - 32) seconds is 2.3283064365386963e-10, which datetime.timedelta rounds to timedelta(0), and math.log2(0.0) then raises. The same happens for a PUZZLE whose Lifetime octet is 0x00, which is a legal encoding meaning 2^-32 seconds:

PUZZLE with Lifetime=0x00 parses to datetime.timedelta(0); rebuild:
  ValueError: expected a positive input, got 0.0
  isinstance(exc, BaseError) = False

So the reachability is not hypothetical and does not need a crafted input: parse a conformant SOLUTION, re-emit it, get a bare ValueError.

The bare exception breaks the house convention

pcapkit/utilities/exceptions.py exists so that "only user error stack information" is shown when an exception reaches the user, and its module docstring describes the whole family as "refined built-in exceptions". The mechanism is BaseError: raising one logs once at logging.CRITICAL and, outside development mode, sets sys.tracebacklimit = 0 so the user sees the exception line rather than a walk through pcapkit's internals. ProtocolError — the type this code already raises for malformed parameters, itself a ValueError subclass — is in that family.

A bare ValueError from math.log2 gets none of it. It is not an instance of BaseError (measured above), so it is invisible to except BaseError, it is not logged, and it arrives with a full internal traceback and the message expected a positive input, which names neither HIP nor the parameter nor the field. The right shape here is the same ProtocolError the readers raise, naming the parameter and the offending value.

Why nothing caught it

examples/generators/options.py:975-977 dodges it explicitly, and records that it is doing so:

        # ``lifetime=0`` reaches ``math.log2(0)``.
        Parameter.PUZZLE: {'lifetime': 1},
        Parameter.SOLUTION: {'lifetime': 1},

So the crash is already documented in the repository, as a generator override rather than as a bug. The override has a second consequence worth spelling out: {'lifetime': 1} sets only lifetime, leaving random and solution at their 0 defaults. That means the round-trip generator exercised the SOLUTION length formula at zero bits — maximally non-discriminating, since every candidate formula agrees there — which is why no fixture ever caught #608. One override written to dodge this defect is the reason the neighbouring one stayed hidden.

Nothing else reaches lifetime=0: the round-trip suite is the only caller, and it overrides.

Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions