Skip to content

fix(hip): pad HIP parameters to the record length RFC 7401 gives, not the contents (#651) - #664

Merged
JarryShaw merged 1 commit into
mainfrom
fix/hip-parameter-padding-651
Sep 23, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/hip-parameter-padding-651

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Fixes #651.

The RFC text, fetched

https://www.rfc-editor.org/rfc/rfc7401.txt, 309,319 octets, sha256
09366b9f83dc80593172304ffcf05f57afd186aacb468ac6170a8fe26944c4be. Section
5.2.1, "TLV Format", verbatim:

   All of the encoded TLV parameters have a length (that includes the
   Type and Length fields), which is a multiple of 8 bytes.  When
   needed, padding MUST be added to the end of the parameter so that the
   total length is a multiple of 8 bytes.  This rule ensures proper
   alignment of data.  Any added padding bytes MUST be zeroed by the
   sender, and their values SHOULD NOT be checked by the receiver.

   The Length field indicates the length of the Contents field (in
   bytes).  Consequently, the total length of the TLV parameter
   (including Type, Length, Contents, and Padding) is related to the
   Length field according to the following formula:

   Total Length = 11 + Length - (Length + 3) % 8;

   where % is the modulo operator.

and, from the same section's field list, Length is "Length of the Contents, in
bytes, excluding Type, Length, and Padding" with Padding "0-7 bytes, added if
needed". So it is the total that must be 8-aligned, and Length is only the
contents.

Where the padding changed

One site, not 95 — for 93 of the 95. Both expressions were byte-identical at every
occurrence (git grep -c '% 8': 46 in schema/internet/hip.py, 49 in
internet/hip.py, and sort -u over the matched lines returns exactly one distinct
line in each file), so 45 of the 46 and 48 of the 49 collapse into the helpers below.
The two remaining sites are LOCATOR_SET's, excluded deliberately — see the #679
section.

def parameter_total_len(length: 'int') -> 'int':
    ...
    return 11 + length - (length + 3) % 8


def parameter_padding_len(pkt: 'dict[str, Any]') -> 'int':
    ...
    length = pkt['len']
    return parameter_total_len(length) - 4 - length

grep '% 8' over the two files now returns five lines, and each one is accounted
for — four in the schema module and one in the protocol module:

schema:338   prose, the ENCRYPTED residue set "Length % 8 in {0, 5, 6, 7}"
schema:382   the RFC 7401 5.2.1 quote in parameter_total_len's docstring
schema:421   its return
schema:578   LocatorSetParameter.padding        <- excluded per #679
protocol:998 _read_param_locator_set's length   <- excluded per #679

The helper takes no version, and RFC 5201 is why

Worth stating because HIPv1 and HIPv2 differ on several parameter field widths,
and #655 is about the builders not consulting version — so a reader could
reasonably ask whether padding needs it too. It does not. RFC 5201 §5.2.1, fetched
(240,492 octets, sha256 8b42d181…9cef6) and quoted rather than paraphrased:

   All of the TLV parameters have a length (including Type and Length
   fields), which is a multiple of 8 bytes.  When needed, padding MUST
   be added to the end of the parameter so that the total length becomes
   a multiple of 8 bytes.  This rule ensures proper alignment of data.
   ...
   Total Length = 11 + Length - (Length + 3) % 8;

The same formula as RFC 7401 §5.2.1, to the character. So parameter_total_len
is correctly version-independent, and nothing here needs to change when #655
lands.

HIP.make's len computation was not the second defect

total_length // 8 + 4 is correct, and RFC 7401 section 5.1.3 says why:

   The Header Length field contains the combined length of the HIP
   Header and HIP parameters in 8-byte units, excluding the first
   8 bytes.

The fixed header is 40 octets, so Header Length = (40 - 8 + params) / 8 = 4 + params / 8. The repo's own note called it "lossless only when the parameter
octets are a multiple of eight" — which is exactly the precondition the padding
fix now establishes, so the floor division is exact rather than exact-in-pairs.
_make_hip_param sums real pack() lengths, every one of which is now a
multiple of 8. Left alone deliberately.

One coupled fix, forced rather than opportunistic

EncryptedParameter.data's length callback subtracted the 16-octet iv but not
the four reserved octets, while _make_param_encrypted writes
len = 4 + len(iv) + len(data). The old record total was therefore
8 + Length + (-Length % 8) against the RFC's 4 + Length + pad.

Correction to an earlier revision of this description, which said those
"coincide only when Length % 8 == 0" — that is wrong, and the earlier table
only sampled residues 0 and 4, which is how it went unnoticed. Measured by
packing through _make_param_encrypted at every residue on 0c7f2b7c9:

Length Length % 8 pre-PR octets RFC octets agree?
4 4 16 8 no
5 5 16 16 yes
6 6 16 16 yes
7 7 16 16 yes
8 0 16 16 yes
9 1 24 16 no
10 2 24 16 no
11 3 24 16 no
12 4 24 16 no

So they agreed at Length % 8 in {0, 5, 6, 7} and differed at {1, 2, 3, 4}
— four residues, not one. Length = 8, which the unit suite happened to use, is
one of the four, which is why the module emitted RFC-conformant ENCRYPTED
octets there while getting both halves wrong.

Fixing the padding alone would still have been a regression, and for a stronger
reason than the original wording gave: with only the padding corrected the total
becomes 8 + Length + pad against a correct 4 + Length + pad, a uniform
four-octet surplus with no residue left where it cancels. So ENCRYPTED
would have gone from right at four of the eight residues to wrong at all eight.
The two go together; encrypted_data_len is the third new helper, and the
residue sets above are now asserted in
test_hip_encrypted_data_length_excludes_reserved_and_iv rather than only
claimed in prose.

Widths, and why they discriminate

The defect was exactly 4 (mod 8), so a convenient spread of multiples of 8
would prove nothing. test_hip_parameter_total_length_matches_the_rfc_7401_formula
sweeps every Length in 0..63 — all eight residues — against a rfc_total
transcribed inline from the RFC rather than imported from the implementation
(comparing an implementation with itself asserts nothing). What each residue
class rules out:

  • Length % 8 == 4 (4 — a whole SEQ; 20 — a whole SOLUTION): the
    RFC wants zero padding. The old rule appended 4; a rule that dropped the
    outer % 8 (8 - (Length + 4) % 8) would append 8. Only this residue
    separates the right answer from both.
  • Length % 8 == 0 (0, 8, 16): contents are 8-aligned alone, so the
    old rule appended nothing and left the record 4 octets short. This is the
    under-padding residue, invisible to any "result is at least as long as the
    contents" check.
  • Length not a multiple of 4 (1,2,3,5,6,7 → pad 3,2,1,7,6,5):
    catches a formula that only ever moves in steps of four, which both the old
    and the fixed one resemble at a glance.

The test also asserts total != pre_651_total(length) at every Length, i.e.
there is no residue at which it would have passed before this change.

A gap the cross-review found, now closed. Eight residues are enough for any
formula that is periodic in Length % 8 — but not for one that is not. A
parameter_total_len masking its argument (length & 0xFF) agrees on 0..63 and
diverges at 256, and nothing in the generator builds a parameter that long, so it
passed the entire suite. The test now also checks the whole domain of the
16-bit len field in one loop. Verified by planting exactly that formula in a
scratch copy of this branch:

421:    length &= 0xFF  # deliberately wrong: masks to 8 bits
RESOLVED pcapkit.__file__ = /tmp/hip651/masked/pcapkit/__init__.py
=== masked formula vs the arithmetic test: exit 1 ===
E  - [256, 257, 258, ...]
E  + [] : parameter_total_len diverges from RFC 7401 5.2.1 at 65280 of the
         65536 representable Length values, first at [256, 257, ...]
1 failed, 2 warnings, 64 subtests passed in 0.73s

Failing, then passing

Both runs import from a tree that is printed and asserted first, with
PYTHONPATH set and the editable finder accounted for (see the trap below).

New tests against the pristine 0c7f2b7c9 library. An earlier revision of this
description gave one figure without saying which files it covered
, which
invited reading it as both modules; here are both, from a run whose library was
checked by md5 against git show 0c7f2b7c9: and whose test files were checked by
md5 against this branch:

RESOLVED pcapkit.__file__ = /tmp/hip651-owner-only/before/pcapkit/__init__.py
CPython 3.14.7
OK
--- tests/protocols/internet/test_hip_unit.py only:  exit 1 ---
76 failed, 22 passed, 2 warnings, 74 subtests passed in 28.31s
--- ... plus tests/protocols/test_option_roundtrip_unit.py: exit 1 ---
79 failed, 28 passed, 2 warnings, 431 subtests passed in 28.96s

Both are real and differ only in file selection. Broken down, because
pytest-subtests counts a failing subtest as its own "failed" entry:

run SUBFAILED FAILED methods "failed" passed methods subtests passed
HIP unit only 71 5 76 22 74
both modules 74 5 79 28 431

The round-trip module adds 3 failures — the two subtests of
test_a_hip_packet_carrying_one_parameter_round_trips, plus the ENCRYPTED case in
test_round_trip_is_identity_or_a_recorded_gap, which on the old library still
MISMATCHes while this branch's table no longer records it.

Worth flagging because it tripped the cross-review: 74 is the number of
subtests that passed in the one-module run and, coincidentally, the number that
failed in the two-module run.
Same integer, different quantity.

The failures are real octet counts rather than an AttributeError — the
octet-level test deliberately imports no new helper:

(maker='_make_param_seq',        length=4)  AssertionError: 12 != 8
(maker='_make_param_esp_info',   length=12) AssertionError: 20 != 16
(maker='_make_param_solution',   length=20) AssertionError: 28 != 24
(maker='_make_param_unassigned', length=0)  AssertionError: 4 != 8
(maker='_make_param_unassigned', length=1)  AssertionError: 12 != 8
(maker='_make_param_unassigned', length=5)  AssertionError: 12 != 16

Over-padding at 4, 1; under-padding at 0, 5. Same tests on this branch:

RESOLVED pcapkit.__file__ = .../worktrees/agent-a5e1c50562a5aab83/pcapkit/__init__.py
OK
=== after_hip2: pytest exit code 0 ===
33 passed, 1 warning, 507 subtests passed in 25.77s

against 30 passed, 434 subtests on 0c7f2b7c9 for the same two modules.

Fixture evidence that does not trust pcapkit at all

examples/generators/make_samples.py regenerates the set (never rm -rf examples/capturesdhcp.pcapng and in.pcap are committed). A standalone
walker then parses options-internet.pcap with no pcapkit, taking its stride
solely from 11 + Length - (Length + 3) % 8, so a record written to any other
rule desynchronises it immediately:

b34f132f6   15006 octets  145 frames  46 HIP frames  98 records  43 violations
this branch 14966 octets  145 frames  46 HIP frames  91 records   1 violation

The one remaining violation is LOCATOR_SET, and it is present identically on
b34f132f6frame 102: type 193 padding not zeroed: 00c10000. The walker trusts
Length, and LOCATOR_SET's Length is in 4-octet units, so it advances 8 octets
into a 32-octet record and desynchronises. That is #679's defect, not the padding's:
the record itself is the RFC's 24n + 8 octets on both trees. So the honest figure
is 43 → 1, with the 1 unchanged from main.

It also checks that each record's padding is zeroed, as 5.2.1 requires of the
sender, and that the header's len accounts for exactly the octets walked.

Read "0 violations" narrowly, though — the cross-review found that it conceals
one record, and it is worth spelling out because it is the same
two-defects-cancelling shape as everything else here.
R1_COUNTER declares
Length = 12 but its schema packs only 12 octets in total, four short of the
RFC's 16, because of the separate counter width defect described below. A
Length-driven reader therefore advances 16 and lands four octets inside the
second copy, where it reads a phantom Type = 0, Length = 0 record out of that
copy's own zeroed contents — whose "padding" is zero, so the zero-padding check
passes and the walk ends tidily on the area boundary. The generator builds
R1_COUNTER with counter = 0 (it has no entry in _hip_overrides()), which is
exactly what makes the desynced octets indistinguishable from real padding.

Measured, by patching only the second copy's counter in a scratch copy of the
file and re-walking:

as generated            : 92 records, 0 violations
with a non-zero counter : 92 records, 1 violations
    frame 101: type 0 padding not zeroed: aabbccdd

So the honest reading is "no padding violations among the records a conformant
reader can find"
, not "the capture is conformant". The padding fix is not
implicated — the concealment is entirely the counter width defect — and
43 → 0 remains a real difference, since all 43 pre-fix violations were padding
ones. Two follow-ups this suggests, neither taken here: give R1_COUNTER a
non-zero counter in the generator's _hip_overrides() so the fixture stops
hiding it (outside this PR's remit for that file, which is HIP_COPIES and its
note), and fix the width itself.

HIP_COPIES = 1, measured — and why it stays at 2

Measured over the generator's 49 HIP codes, at both settings, on both trees:

tree one copy two copies
0c7f2b7c9 4 OK 45 OK
this branch 45 OK 46 OK

So 41 codes that could not survive alone now can, which is the strongest
available statement that the padding was what made a lone parameter
unrepresentable — and better evidence than any assertion in this PR.

It still stays at 2, because the four codes that fail at one copy fail for
reasons unrelated to padding: R1_COUNTER and R1_Counter (the counter width),
HOST_ID (declares len=8, packs 18) and HIP_TRANSFORM (HIPv1-only, built here
at version 2). Flipping the constant would trade this fix's workaround for one new
expected-failure entry, one changed status, and the loss of R1_COUNTER from the
round-tripping set — a change about those defects.

R1_COUNTER deserves a sentence, because it is the one code that round-trips at
one copy before this PR and not after, which looks like a regression and is not.
At Length = 12 the old contents-aligning rule appended exactly four surplus
octets, and those happened to fill this parameter's own four-octet shortfall,
bringing the record to 16. Two defects cancelling. Correcting the padding removes
the compensation and leaves the shortfall visible, which is the right outcome; at
two copies the shortfall sums to 8 and the code round-trips either way.

The single-parameter case is not lost meanwhile — it is asserted directly, and
now positively, by test_a_hip_packet_carrying_one_parameter_round_trips.

EXPECTED_FAILURES: exactly one entry moved

Correction to an earlier revision of this description: it said the table is
"**-free", which is wrong — 30 of its entries are produced by two
**{f'pcapng-option/{name}': Gap(...) for name in (...)} comprehensions
(test_option_roundtrip_unit.py:460 and :504), which is exactly why grepping
it cannot enumerate it. It was therefore checked two independent ways instead:

  1. All 322 cases run on both trees and the statuses diffed — one line differs,
    hip-parameter/ENCRYPTED MISMATCHOK.
  2. The table imported from each tree by file path and compared structurally:
entries: 45 -> 44
removed : ['hip-parameter/ENCRYPTED']
added   : []
changed : []

So:

  • hip-parameter/ENCRYPTED: MISMATCHOK, entry deleted. Not deleted
    merely because it passes: its two recorded causes are both genuinely gone (the
    cipher= half by Two dropped-keyword/wrong-cast defects flagged in review and never filed (hip.py:3533, ipv6_route.py:207) #556, the data-length half by this PR), and the reasoning
    is kept as prose where the entry was, in this module's existing style.
  • Nothing else moved — changed : [] is the whole check. hip-parameter/R1_Counter,
    hip-parameter/HIP_TRANSFORM and hip-parameter/HOST_ID keep their recorded
    status, fragment and defect string byte for byte.
  • The record of the 6-octet UInt32Field pack asymmetry was not touched.
    Note it is not a Gap entry at all but the prose block at
    test_option_roundtrip_unit.py:205-230, explaining that SIDOption.sid was a
    UInt32Field where RFC 791 §3.1 gives the Stream ID 16 bits, so the option
    re-emitted as 880400000037 where the wire holds 88040037. git diff 0c7f2b7c9 over that file matches none of UInt32Field, UInt16Field,
    SIDOption or 880400000037, i.e. the block is untouched.

test_a_single_hip_parameter_cannot_be_constructed is renamed to
test_a_hip_packet_carrying_one_parameter_round_trips and inverted — it required
the library to reject its own single-parameter packets, which is the defect.

Narrowed per #679: this fixes 45 of 46 parameters, not all 46

An earlier revision of this PR corrected the padding at every HIP parameter, and
that made LOCATOR_SET four octets short — it had been accidentally conformant, by
two defects cancelling. The owner's decision on #679 is option (b): narrow this PR
and leave LOCATOR_SET alone
, so the pair can be fixed together there. Done, at
both of that parameter's sites.

Why it was already right. Two defects cancel exactly:

  1. LocatorSetParameter.padding's callback never receives the parameter's len.
    ListField packs each nested Locator into the shared packet context, whose own
    len key overwrites the parameter's, and padding is evaluated after the list —
    so the value seen is the last locator's len, always 4 for an IPv6 locator.
    Measured by building the schema directly with a parameter len = 9 over one
    locator of len = 4: it pads by the amount for 4, not the 3 that 9 would give.
  2. _make_param_locator_set sets the parameter's len to sum(Locator.len), in
    4-octet units, where RFC 7401 §5.2.1's Length is a byte count — 4n where
    the contents are 24n octets.

Always-4 padding gives 4 + 24n + 4 = 24n + 8; and because 24n ≡ 0 (mod 8), the
RFC total for Length = 24n is 11 + 24n - 3, the same 24n + 8. Identical for
every locator count.

Verified byte-for-byte against b34f132f6, not merely "a test passes":

n contents = 24n this branch b34f132f6 RFC total for Length = 24n
1 24 32 32 32
2 48 56 56 56
5 120 128 128 128

and the two literals the corekit field test pins come back identical on both trees:

ip='2001:db8::1' : 00c10004000004000000000020010db800000000000000000000000100000000
ip=int(True)     : 00c1000400000400000000000000000000000000000000000000000100000000

The exclusion is documented at the site, not as a cross-reference. The comment on
LocatorSetParameter.padding opens by telling the next reader not to "finish" #651 by
changing that line alone, and gives the arithmetic for why. The matching reported
record length in _read_param_locator_set is left on the old expression too, with a
pointer to it, so the parameter is unchanged in its data model as well as its octets.

And the exclusion is guarded, so it can neither grow nor evaporate.
test_hip_padding_helpers_cover_every_parameter_but_locator_set introspects each
parameter schema's declared padding field and asserts the taxonomy:

parameter schema classes        : 49
  on parameter_padding_len      : 45
  excluded (old expression)     :  1  ->  ['LocatorSetParameter']
  declare no padding field      :  3  ->  RegFromParameter, RelayFromParameter,
                                          RelayToParameter

Those three are correct rather than an omission, and unchanged by this PR: their
contents are a fixed 20 octets, and 11 + 20 - (20 + 3) % 8 == 24 == 4 + 20, so the
RFC asks for no padding. Measured on both trees — all three pack to 24 octets. They
are listed by name in the assertion so that a fourth appearing there would mean a
parameter had quietly lost its padding field.

Found and deliberately not fixed## Found and deliberately not fixed

R1_COUNTER's counter is four octets where RFC 7401 section 5.2.3 requires
eight.
The RFC diagram reads "R1 generation counter, 8 bytes" with
Length 12, and the prose says "a 64-bit unsigned integer in network byte
order"; R1CounterParameter.counter is a UInt32Field, so the schema declares
len=12 and packs 12 octets where the RFC total is 16. HIP_COPIES = 2 hid it,
because two identical 4-octet misalignments sum to 8 — and, before this PR, the
old padding rule hid it a second way by appending exactly the four octets it was
missing. This is the same shape of defect as #608 and wants its own issue and
review — not folded in here. Its consequences show up twice above: it is why
R1_COUNTER no longer round-trips at one copy, and why the fixture walker's
"0 violations" conceals one record. Now filed as #672, carrying the RFC §5.2.3
quote, the file:line, the measured octet counts for both codes 128 and 129, the
three separate things that were hiding it, and the walker reproduction.

Also not fixed, and also pre-existing: _make_hip_param's bytes and Schema
branches are unreachable.
Both append packed octets to a list that
Schema_HIP.param packs as a ListField of SchemaFields, so
HIP(parameters=[b'...']) and HIP(parameters=[some_schema]) both raise
AttributeError: 'bytes' object has no attribute 'type' — identically on
0c7f2b7c9 and on this branch. Worth recording because it is what makes the
total_length // 8 + 4 truncation hole unreachable in practice: only the
(code, kwargs) branch works, and every parameter it produces is 8-aligned after
this fix. On 0c7f2b7c9 that same one-parameter call raised
ProtocolError: HIPv2: invalid format; on this branch it yields a 48-octet packet
with Header Length = 5, which is the whole fix in one line.

Also recorded, for the sibling #653/#654/#655 work rather than for this PR:
hip-parameter/HIP_TRANSFORM's defect string in EXPECTED_FAILURES blames
"the make-side arithmetic", but measured on this branch the parameter packs 8
octets for len=0, exactly the RFC total. Its actual cause is that
_read_param_hip_transform raises for any version != 1 while the generator
builds it at version 2 — which sounds like #655's territory, so the entry is
left untouched here.

Not touched

protocols/protocol.py, application/http.py, corekit/io.py,
dumpkit/common.py, transport/tcp.py, tests/_support.py, misc/pcapng.py,
.github/**, docs/source/changelog/1.5.0.rst, CHANGELOG.md. No changelog
bullet on this branch
— that goes to #657 / docs/changelog-1.5.0 separately.

Traps hit while measuring, for the record

PYTHONSAFEPATH=1 is what causes the editable-install trap rather than
avoiding it. __editable__.pypcapkit-1.4.1.post2.pth installs an
_EditableFinder hard-wired to MAPPING = {'pcapkit': '/local/home/jarryx/GitHub/PyPCAPKit/pcapkit'} — the main checkout — and
PYTHONSAFEPATH=1 removes the script-directory entry that would otherwise shadow
it. Measured: the first make_samples.py run in this session produced an
options-internet.pcap carrying pre-fix 20-octet ESP_INFO records while the
worktree's own library packed 16, because the generator had silently imported the
main checkout. Every measurement above was redone with PYTHONPATH=<tree> and a
printed, asserted pcapkit.__file__.

Second, the finder is appended to sys.meta_path as a class, so
type(f).__module__ reads builtins and a filter on that misses it entirely;
f.__module__ is what carries the __editable__ prefix.

@JarryShaw JarryShaw added fix Pull requests that fix a defect (fix: subject prefix) breaking Alters public API or wire output (apply alongside the type label) labels Sep 22, 2026
@JarryShaw
JarryShaw force-pushed the fix/hip-parameter-padding-651 branch from 749fb9a to 32b6b65 Compare September 22, 2026 18:29
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…t 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 0c7f2b7 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.
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review: GOOD TO GO

Independent cross-review of 32b6b65a4 (then at 749fb9a47) by an agent on a
different model — Sonnet, where the change itself was authored on Opus. It was
briefed to falsify rather than to bless: to re-derive RFC 7401 §5.2.1's arithmetic
from the RFC text before reading any code, to hunt for a wrong formula that still
passes the new tests, and to try to find a real HIP capture to check against.

Verdict: GOOD TO GO, with four follow-ups, none of them blocking. All four
have been acted on and are folded into 32b6b65a4 and the description above.

What it confirmed independently

  • The RFC formula and the implementation agree. It fetched
    rfc7401.txt itself (same sha256, 09366b9f…44c4be, 309,319 octets), derived
    the padding arithmetic from §5.2.1's text before reading the code, and then
    compared its own derivation against parameter_total_len /
    parameter_padding_len for every Length 0–255 with zero disagreements,
    spot-checked to 65535. Same formula, not a different one.
  • All 95 sites, and no others. 46 + 49 identical expressions on the base,
    collapsing to two % 8 occurrences on the branch (the docstring quote and the
    one return). It then swept the whole pcapkit/ tree, found 18 other %8
    hits, and inspected each: all structurally different, already-correct mechanisms
    in IPv6/MH/TCP/pcapng. None shares the HIP pattern.
  • HIP.make's total_length // 8 + 4 is correct, §5.1.3 quote checked.
  • The EncryptedParameter.data coupling is necessary, derived independently
    from §5.2.18 and confirmed against real packed bytes on both trees.
  • Coverage, the HIP_COPIES table, and the EXPECTED_FAILURES diff all
    reproduced exactly, the last by importing the table from each tree and comparing
    structurally rather than grepping it.
  • House rules: one commit, Fixes #651 present, in-library exceptions only,
    .rst not .md, no GH-nnn, no changelog file touched, none of the nine
    off-limits files in the diff.

What it disputed, and what changed as a result

  1. A prose overstatement in this PR, now corrected. The description claimed
    the old ENCRYPTED total and the RFC's "coincide only when
    Length % 8 == 0". False — they also coincide at residues 5, 6 and 7. The
    earlier table only sampled residues 0 and 4, which is how it went unnoticed.
    Re-measured at every residue: they agreed at {0, 5, 6, 7} and differed at
    {1, 2, 3, 4}. Corrected in the description, in encrypted_data_len's
    docstring, in the test docstring and in the EXPECTED_FAILURES deletion note —
    and the residue sets are now asserted in
    test_hip_encrypted_data_length_excludes_reserved_and_iv rather than only
    claimed. The coupling argument is unaffected and in fact stronger: with only
    the padding corrected there is no residue left where the errors cancel.

  2. A real gap in the new tests, now closed. Eight residues pin any formula
    periodic in Length % 8, but the sweep stopped at 63, so a
    parameter_total_len masking its argument (length & 0xFF) agreed on 0–63,
    diverged at 256, and passed the entire suite. The test now also checks the
    whole 16-bit domain of the len field. Verified by planting exactly that
    formula in a scratch copy: it now fails naming the first divergence at
    Length = 256 and the count 65280.

  3. The fixture walker's "0 violations" conceals one record. This is the most
    substantive finding and I reproduced it myself. R1_COUNTER declares
    Length = 12 but packs 12 octets rather than 16, so a Length-driven reader
    advances 16 and reads a phantom Type = 0, Length = 0 record out of the second
    copy's zeroed contents — whose "padding" is zero, so the check passes. Patching
    only that copy's counter to aabbccdd turns 0 violations into 1
    (frame 101: type 0 padding not zeroed: aabbccdd). The padding fix is not
    implicated; the concealment is entirely the counter width defect. The
    description now states the caveat explicitly instead of citing "0 violations"
    bare.

  4. File the R1_COUNTER 64-bit-width issue. Agreed and unchanged in
    substance: it is real, pre-existing, out of scope here, and now cited in three
    places above. I have not opened the issue because that was not in my remit;
    it needs one line of go-ahead.

Two corrections in the other direction

Reported for the record, because a cross-review is only worth having if it is
also checked:

  • The review reported the description's 76 failed, 22 passed, 74 subtests passed as wrong, giving 79 failed, 28 passed, 431 subtests passed and reading
    74 as a failed count. Re-measured with the library md5-checked against
    git show 0c7f2b7c9: and the test files md5-checked against this branch: both
    figures are right and differ only in file selection
    — 76/22/74 for
    test_hip_unit.py alone, 79/28/431 with the round-trip module added. And 74
    really is the passed-subtest count in the one-module run; it is also,
    coincidentally, the failed-subtest count in the two-module run, which is what
    crossed the wires. The description now gives both with a breakdown. The original
    fault was mine: it quoted one figure without saying what it covered.
  • One of the review's own sub-agents reported an R1_COUNTER regression where a
    standalone packet "succeeds pre-fix, raises post-fix". The lead reviewer
    re-ran it and it did not reproduce, and my own measurement agrees: it raises
    identically on both trees. The real effect is the round-trip-at-one-copy change
    explained above, which is two defects ceasing to cancel rather than a
    regression.

Could not verify

No real third-party HIP capture was found. The Wireshark SampleCaptures wiki
returned HTTP 403, the automated-captures mirror is a fuzzer corpus rather than
curated samples, and the HIPL and OpenHIP source trees carry no capture files. So
the conformance evidence here is the RFC arithmetic and the independent
RFC-only walk over the generated capture, not a packet from a real peer. Stated
plainly rather than glossed: if anyone has a HIP capture, it is the one check this
PR cannot make.

On the breaking label

breaking is right, and for a reason independent of the "the old bytes were
plainly wrong anyway" counter-argument: the fix changes Data_*Parameter.length,
a documented public attribute of the parse result, for every HIP parameter —
a SEQ now reports length = 8 where it reported 12. Code asserting on that
attribute breaks, and unlike the emitted octets there is no sense in which the old
value was "obviously" wrong to a caller reading the data model. The wire-output
change qualifies on its own too, since this repo's label contract defines
breaking as "changes public API or wire output".

JarryShaw added a commit that referenced this pull request Sep 22, 2026
The bullet #664 would otherwise have carried, kept here so that #664 touches only
`pcapkit/protocols/internet/hip.py`, `pcapkit/protocols/schema/internet/hip.py`,
`tests/protocols/internet/test_hip_unit.py`,
`tests/protocols/test_option_roundtrip_unit.py`,
`examples/generators/options.py` and `docs/source/pcapkit/protocols/internet/hip.rst`.

One bullet, because it is one root cause in 95 places: every HIP padding site
aligned the parameter's *contents* to eight octets rather than the record,
ignoring the four-octet type-and-length header, so every parameter pcapkit wrote
was `4 (mod 8)` for every possible `Length`.

The bullet says in bold that both the emitted octets and the data model's
reported `length` change, and carries a migration sentence: a `SEQ` parameter's
`length` is 8 where it was 12, so code comparing stored output byte for byte or
asserting on `Data_*Parameter.length` sees different values.

It also records what was deliberately *not* changed, since both look like part of
the same defect and are not: `HIP.make`'s `len = total_length // 8 + 4`, which
RFC 7401 section 5.1.3 shows is correct and merely needed 8-aligned parameters;
and `HIP_COPIES`, which stays at two for `R1_COUNTER`'s four-octet `counter`
against section 5.2.3's eight -- a separate, still-unfiled defect this one had
been masking. The `EncryptedParameter.data` length callback is named as fixed in
the same change because the two four-octet errors cancelled at four of the eight
residues of `Length`, so correcting the padding alone would have regressed it.

41 lines added to the entry file; `CHANGELOG.md` regenerated with
`util/changelog_md.py`, not edited. `--check` exits 0 and `tests/project/` is
green at 96 passed, 469 subtests.

Committed from a detached HEAD on 2ce3687 and pushed to the branch ref, because
`docs/changelog-1.5.0` is checked out in another agent's worktree at a stale
f846523 and could not be taken here. Note 2ce3687, not the 69a6e13 I was
given: the branch had already moved on with #665's entry.
@JarryShaw

Copy link
Copy Markdown
Owner Author

Interaction with #665not order-independent, and the reason is in #665's fixtures

Recorded here rather than only in a handoff note, because whoever merges these two
needs it and the conflict resolution is where the fix belongs.

git merge-tree between the two heads reports exactly one conflict, in
tests/protocols/internet/test_hip_unit.py. No library file conflicts: this PR's
46 PaddingField lines and 49 length= lines sit on different lines from #665's
lifetimereserved rename, its rhash_len additions and its rewritten
_make_param_puzzle / _make_param_solution bodies. The textual collision is only
that both PRs append new test methods after the same existing one.

But the two are coupled on correctness, in both directions, and the coupling is
not visible in the conflict.
A real three-way merge of the two heads was built in
a disposable clone and the suites run against it: test_option_roundtrip_unit.py
is clean (6 passed, 360 subtests) and this PR's octet assertions all hold, but
two of #665's own tests failtest_hip_puzzle_and_solution_keep_the_on_wire_field_width
and test_hip_solution_second_octet_is_reserved_not_a_lifetime — each on
bytes(rebuilt) == wire, with rebuilt four octets shorter than the fixture. Both
pass on #665 alone.

The cause, verified directly against #665's diff: its hand-written wire literals
encode the padding this PR removes. For example

wire = bytes.fromhex('0141' '0014' '01' '20' '6f70'
                     '0000000000000001' '0000000000000001' '00000000')
self.assertEqual(len(wire), 28)

That is a SOLUTION with Length = 0x0014 = 20, and RFC 7401 §5.2.1 gives
11 + 20 - (20 + 3) % 8 = 24 octets, not 28 — the trailing 00000000 is the
contents-aligned padding this PR corrects. Same shape in the PUZZLE literal
(Length = 12, asserted at 20 octets where the RFC gives 16) and in both the
conformant and received literals of the Reserved test. So #665's fixtures
currently assert non-RFC-conformant octets
, which is not a criticism of #665
they are faithful to the tree it was written against — but it does mean the trim
is a correctness improvement there and not merely a merge chore.

Four literals need the trailing 00000000 dropped and their length assertions
moved 28 → 24 and 20 → 16. A fifth, in
test_hip_puzzle_lifetime_guard_raises_an_in_library_error, carries the same stale
padding but is never byte-compared, so it passes either way; worth trimming for
consistency.

The coupling runs the other way too, and one line of this PR is on the hook.
test_hip_parameter_records_are_eight_octet_aligned_on_the_wire builds its
SOLUTION case with _make_param_solution(..., lifetime=1, ...), and it must —
on main a lifetime of 0 reaches math.log2(0), which is #654. #665 removes
that keyword, after which it is absorbed by **kwargs and does nothing. Measured:
the test still passes in the combined tree. But it is a dead keyword at that point
and should be dropped in the same rebase, rather than left depending on **kwargs
tolerance.

Recommended order: this PR first, then rebase #665 and make both edits as part
of that rebase — the four literals and the dead lifetime=1. The dependency exists
either way round; taking it in #665's rebase puts it where the failure is
self-explanatory, instead of surfacing later as an unexplained regression blamed on
this PR.

One thing that is not an interaction, checked because it looked like one: HIPv1
and HIPv2 state the same padding rule. RFC 5201 §5.2.1 gives
Total Length = 11 + Length - (Length + 3) % 8, character for character identical
to RFC 7401 §5.2.1, so parameter_total_len correctly takes no version and
#655's version work does not reach it. Both RFCs were fetched and quoted (RFC 5201
sha256 8b42d181…9cef6).

@JarryShaw

Copy link
Copy Markdown
Owner Author

Correction: #664 and #665 are order-independent. My earlier comment was wrong.

Retracting the load-bearing claim of
the interaction note above.
Left in place rather than edited away, because what it asserted and on what basis
is worth keeping on the record. #665 carries a mirror-image claim on its own PR
that needs the same correction.

What I got wrong, and how

I did not build or run the merge before posting that comment. I verified the
premise — that #665's wire literals carry the pre-#651 padding — directly, by
reading gh pr diff 665, and that part is true and still true: the SOLUTION
literal is 28 octets for Length = 20 where RFC 7401 §5.2.1 gives 24, the PUZZLE
literal is 20 for Length = 12 where it gives 16. But I took the conclusion — that
this makes two of #665's tests fail in a combined tree — from the interaction
agent's report without running it myself. Verifying the premise and inheriting the
conclusion is the whole of the error, and the arithmetic in that comment being
independently right is exactly what made it look checked.

The conclusion is false, because #665's author already made those tests
padding-agnostic and said so.
In
test_hip_puzzle_and_solution_keep_the_on_wire_field_width:

# 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) ...
self.assertEqual(bytes(rebuilt)[:4 + rebuilt.len], wire[:4 + unpacked.len])
self.assertEqual(bytes(rebuilt), bytes(unpacked))

The first assertion slices the padding off; the second compares two schemas
re-packed under whatever rule is in force. Same shape in
test_hip_solution_second_octet_is_reserved_not_a_lifetime
(bytes(rebuilt)[:4 + rebuilt.len], bytes(rebuilt) == bytes(received_schema),
bytes(sanitised) == bytes(conformant_schema)). So the trailing 00000000 in
those literals is only ever a source buffer to unpack, never a comparand, and
#664 cannot move them. The four literals do not need trimming, and keep-both is
the right resolution.

The run I should have done first

Two disposable clones under /tmp/hip651-mrg2 and /tmp/hip651-mrg3 (private
paths — an earlier shared one got raced by a concurrent agent), both at the current
pushed heads:

pr664 32b6b65a4ab12b887941afc48f2c719795c34d9c
pr665 ebc929cd27d0d2111cf365a03f0b384db1a9f20b
merge-base 0c7f2b7c9c57eaae91704fad5a166ca778c662c8
git clone --shared --no-checkout <worktree> repo
git branch -f pr664 32b6b65a4 && git branch -f pr665 ebc929cd2
git checkout pr665 && git merge --no-ff --no-edit pr664     # and the reverse

Both library files auto-merge; one conflict, tests/protocols/internet/test_hip_unit.py.
Resolution: keep-both, applied by script so it is reproducible — the regex
^<<<<<<< HEAD\n(.*?)^=======\n(.*?)^>>>>>>> pr664\n replaced by group(1) + group(2), i.e. HEAD's block then the incoming block, in that order, nothing else
touched. Union verified rather than assumed:

test counts:  merge-base 24   pr664 27   pr665 29   merged 32   (= 24 + 5 + 3)
conflict markers remaining: 0
each of the 8 new test names: exactly 1 occurrence
py_compile: exit 0

Provenance asserted in-process before any other import, with PYTHONPATH set and
no PYTHONSAFEPATH (which is what causes the editable-finder trap here, since it
strips the entry that shadows a finder hard-wired to the main checkout):

RESOLVED pcapkit.__file__ = /tmp/hip651-mrg2/repo/pcapkit/__init__.py
CPython 3.14.7
OK

Exit codes read from a file, not off a pipeline:

=== pr665 + pr664 ===
test_hip_unit.py                 exit 0   32 passed, 168 subtests passed
test_option_roundtrip_unit.py    exit 0    6 passed, 360 subtests passed
the four tests in question       exit 0    4 passed,  73 subtests passed
FAILED+SUBFAILED lines: 0 in all three

=== pr664 + pr665 (reverse order) ===
both modules                     exit 0   38 passed, 528 subtests passed
FAILED+SUBFAILED lines: 0

32 passed, 168 subtests reproduces the coordinator's figure exactly. Both
orders are clean, so the two PRs are order-independent on correctness and
keep-both is safe in either direction.

What survives from the original note

Why the earlier finding was ever true

Not stale by sha on my side — 32b6b65a4 predates my comment by 18 minutes — but
stale by provenance: the interaction agent measured #664 at 749fb9a47, and
whatever #665 head it used, the two tests it saw fail were comparing
bytes(rebuilt) == wire against the literal. At ebc929cd2 they do not. So the
finding was real when it was made and had already been fixed at the source by the
time I repeated it. Repeating someone else's failing run as though it were mine is
the thing to not do again.

@JarryShaw
JarryShaw force-pushed the fix/hip-parameter-padding-651 branch 2 times, most recently from d0d8b8c to 819dbbd Compare September 22, 2026 20:45
@JarryShaw
JarryShaw force-pushed the fix/hip-parameter-padding-651 branch from 819dbbd to 9f17491 Compare September 22, 2026 22:13
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…ly fixes

#664 was narrowed per the owner's decision in #679: `LOCATOR_SET` is excluded and
keeps the pre-#651 padding expression, so the change corrects 45 of the 46 HIP
parameters rather than all of them. This entry claimed all of them, which would
have shipped a false statement about wire output in the release notes.

Three claims narrowed, in place rather than as a second bullet:

- the opening, from "every HIP parameter" to 45 of 46, naming the exclusion;
- the site counts, from "the 46 callbacks and the 49 record lengths ... instead of
  95 times" to 93 of the 95 sites, 45 of 46 and 48 of 49;
- the migration sentence, which said every other parameter's `length` moves
  likewise -- now the other 44, with `LOCATOR_SET` called out as unchanged in both
  its octets and its data model.

Added the reason for the exclusion, because a reader who meets it in the code
otherwise cannot tell it from an oversight: two defects in that parameter cancel
exactly -- the padding callback never receives the parameter's `len`, since the
nested `Locator` schemas share a packet context whose own `len` shadows it, and the
parameter's `len` is in 4-octet units where the RFC's `Length` is a byte count.
Always-4 padding gives `4 + 24n + 4`, and because `24n` is a multiple of 8 the RFC
total for a byte-count `Length` of `24n` is the same `24n + 8`. Measured at
n = 1, 2, 5 as 32, 56 and 128 octets on `b34f132f6` and on #664's head alike, so
correcting only the padding would have taken a conformant parameter to four octets
short. #679 carries the pair.

32 lines changed in the entry file; `CHANGELOG.md` regenerated with
`util/changelog_md.py`, not edited. `--check` exits 0 and `tests/project/` is green
at 96 passed, 469 subtests.

Committed from a detached HEAD on aef0fb9 and pushed fast-forward to the branch
ref, because `docs/changelog-1.5.0` is checked out in another agent's worktree and
could not be taken here.
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…t values (#653, #654, #655) (#665)

`_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 0c7f2b7 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.
… the contents

Every padding site in the two HIP modules computed the pad from the contents
length alone -- `(8 - (pkt['len'] % 8)) % 8` -- which aligns `Length` rather
than `Length + 4`. RFC 7401 section 5.2.1 requires the *total* length of a TLV
parameter, Type and Length fields included, to be a multiple of 8, and states
the arithmetic outright: `Total Length = 11 + Length - (Length + 3) % 8`.
Aligning the contents instead put every parameter pcapkit wrote at 4 (mod 8),
for every possible `Length`: measured across 0..63, never a multiple of 8 and
never the RFC's answer, both over- and under-padding by turns.

- Replace 93 of the 95 copies of that expression -- 45 of the 46 `PaddingField`
  callbacks in `schema/internet/hip.py` and 48 of the 49 data-model record
  lengths in `internet/hip.py` -- with one `parameter_total_len()` stating the
  RFC formula once, plus a `parameter_padding_len()` the padding fields share.
  So this is a padding correction for **45 of the 46 HIP parameters**.
- Leave `LOCATOR_SET` on the old expression, deliberately, at both of its sites,
  with the reason at the site rather than as a cross-reference. Two defects there
  cancel exactly: `padding`'s callback never receives the parameter's `len`
  (`ListField` packs each nested `Locator` into the shared packet context, whose
  `len` shadows it, so the value seen is always 4 for an IPv6 locator), and the
  parameter's `len` is `sum(Locator.len)` in 4-octet units where the RFC's
  `Length` is a byte count. Always-4 padding gives `4 + 24n + 4 = 24n + 8`, and
  because `24n` is a multiple of 8 the RFC total for `Length = 24n` is the same
  `24n + 8`. Measured at n = 1, 2, 5 on this branch and on `a18846c8f`: 32, 56
  and 128 octets on both, equal to the RFC total each time. Correcting the
  padding alone would leave `24n + 4`, so #679 fixes the pair together. That
  cancellation covers plain IPv6 locators only, and the comment says so: an empty
  set packs 4 octets where the RFC wants 8, one SPI locator packs 35, two pack 63,
  and a mixed pair packs 59 or 60 -- none 8-aligned, and all byte-identical before
  and after, which is the point. A new
  `test_hip_padding_helpers_cover_every_parameter_but_locator_set` asserts the
  exclusion is exactly one parameter and which one, so it cannot grow or vanish
  unnoticed.
- Fix `EncryptedParameter.data`'s length callback to subtract the four
  `reserved` octets as well as the `iv`. Not opportunism: the two four-octet
  errors cancelled at four of the eight residues of `Length` (measured,
  `Length % 8` in {0, 5, 6, 7}), so fixing the padding alone would have taken
  `ENCRYPTED` from right at those four to four octets too long at all eight.
- `HIP.make`'s `total_length // 8 + 4` was *not* wrong; it is exact now that
  each parameter is 8-aligned, and RFC 7401 section 5.1.3 defines Header Length
  as the header and parameters in 8-byte units excluding the first 8.
- Invert the pin the old behaviour had: a one-parameter HIP packet is accepted,
  not rejected. `HIP_COPIES` stays at 2 for unrelated defects its note now
  names, chiefly `R1_COUNTER`'s 4-octet `counter` where section 5.2.3 requires 8
  (filed as #672).
- Drop `hip-parameter/ENCRYPTED` from `EXPECTED_FAILURES`; its cycle closes.
- Add a note, and no change in value, to the two LOCATOR_SET byte pins in
  `tests/corekit/test_fields_ipaddress.py`. Those literals went red on an earlier
  revision of this branch, which is what surfaced the cancellation; with
  LOCATOR_SET excluded they are correct again and byte-identical to `a18846c8f` --
  a diff of that file against origin/main shows two comment lines and no changed
  literal. The note records why they are already right, and why a shorter pin here
  would silently bless the four-octet shortfall.

Verified against the RFC rather than against a round trip, which cannot see
this defect because pcapkit's writer and reader shared the error. The arithmetic
is checked over all eight residues as subtests and over the whole 16-bit domain
of the `len` field, so a formula that agrees on small values and diverges later
cannot pass. HIP unit and round-trip suites 39 passed / 528 subtests against
`a18846c8f`'s 35 / 455, and the RFC-only walk over the regenerated
`options-internet.pcap` goes from 43 violations to 1 -- the remaining one being the
empty `LOCATOR_SET`, which packs 4 octets where the RFC wants 8, identically on
`a18846c8f`, and is owned by #679.

Fixes #651
@JarryShaw
JarryShaw force-pushed the fix/hip-parameter-padding-651 branch from 4729f3b to 45a3e1d Compare September 22, 2026 22:55
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review of the narrowing: GOOD TO GO

Second cross-review, on a different model — Sonnet — against 9f17491ce, the
first revision to carry the #679 narrowing. The earlier GOOD TO GO on this PR
predates the narrowing and does not cover it; the reviewer said so unprompted,
having checked the commit and decision timestamps itself. Briefed to attack the
narrowing specifically: whether LOCATOR_SET really is byte-identical to main,
and whether the exclusion leaks.

Verdict GOOD TO GO, with three findings. All three were real, I reproduced each
independently, and all three are now folded into 45a3e1d68.

What it confirmed, by methods other than mine

  • Byte-identity, over 15 construction cases and 3 parse cases rather than the
    three lengths this PR quoted: plain n=1..6, the SPI/LocatorData variant n=1..3,
    a mixed traffic/type/preferred/timedelta case, a mixed SPI+plain case, and the
    empty set — plus _read_param_locator_set on a 2-locator mix, a len-mismatched
    single locator, and an empty one, comparing the reconstructed data model including
    .length. diff exit 0, zero differences.
  • The exclusion is structurally incapable of leaking, which is a better argument
    than my count. grep -n "item_type=" schema/internet/hip.py finds 17 ListFields,
    and LocatorSetParameter's is the only one whose item_type is a
    SchemaField — i.e. a nested Schema, whose own pack() does
    packet.update(self.__dict__) and so can overwrite the parent's len. Every other
    ListField uses EnumField / UInt32Field / IPv6AddressField, none of which
    touch the shared context. So no other parameter can have this defect.
  • It also called the raw LocatorSetParameter.padding callback directly over
    len 0..39 and confirmed it disagrees with the RFC formula at all 40 values —
    proving the line really is the pre-HIP parameter padding aligns the contents, not the record, so every parameter pcapkit emits is 4 (mod 8) octets #651 expression and not a disguised helper.
  • A genuine gap in my evidence, closed. HOST_ID and HIP_TRANSFORM never
    appear in options-internet.pcap (their builders fail in the generator, [left out] in both trees' logs), so the walker's "1 violation" never exercised
    HOST_ID's padding at all. It built HostIDParameter directly over five
    hi_len/di_len combinations: all five match the RFC on this branch, all five
    mismatch on main. It independently reproduced the ENCRYPTED residue sets
    {0,5,6,7} / {1,2,3,4} by sweeping dlen 0..19, and checked the five % 8
    lines against the line numbers in this description byte-for-byte.

The three findings, all reproduced and fixed

  1. My frame-102 explanation was wrong. I wrote that the walker "advances 8 octets
    into a 32-octet record". It does not: frame 102's parameter area is
    00 c1 00 00 00 c1 00 00two empty LOCATOR_SETs, type=193, len=0, four
    octets each, where 11 + 0 - 3 = 8 is required. The walker advances 8 and reads
    the second copy's header as padding. Corrected here and in the commit message.

  2. "Identical for every locator count" overstated the cancellation. It holds for
    homogeneous plain-IPv6 sets only. Measured by me on this branch and on
    a18846c8f, identically on both:

    case declared len packed mult of 8? RFC for byte-count Length
    empty, n=0 0 4 no 8
    plain, n=1 4 32 yes 32
    plain, n=2 8 56 yes 56
    SPI, n=1 5 35 no 32
    SPI, n=2 10 63 no 64
    plain then SPI 9 59 no 56
    SPI then plain 9 60 no 56

    An SPI locator is 28 octets, not 24, so 24n ≡ 0 (mod 8) does not apply; and an
    empty set has no locator to shadow len at all. Every one of those shapes is
    non-conformant before and after, byte-identically — which is precisely why
    leaving the line alone is the safe choice rather than the correct one. The
    code comment now says exactly that, and LOCATOR_SET is conformant only by accident: nested Locator.len shadows the parameter in padding, and len is in 4-octet units where RFC Length is bytes #679 has been told it must account for
    all these shapes and not just the homogeneous one.

  3. The suite count was stale. I quoted "33 passed"; it is 34 on the sha reviewed
    and 39 now (the guard test, plus fix(hip): PUZZLE and SOLUTION builders must stop inventing wire-format values (#653, #654, #655) #665's five arriving in main). Re-measured:
    39 passed / 528 subtests here against a18846c8f's 35 / 455.

One correction in the other direction

The reviewer flagged its 34-vs-my-33 as possibly a plugin difference. It was not:
34 was simply right and my figure predated the guard test I had added. Noting it
because the reviewer was inclined to excuse a discrepancy that was mine.

Not verified, and said so

No CRUX/AutoSDE-equivalent gate exists on this repo, so there was nothing of that
kind to check, and the reviewer did not re-derive the #665 interaction analysis —
which has since been settled by main itself: #665 merged as c0e9af933, this
branch is rebased over it, and the append-append conflict in
tests/protocols/internet/test_hip_unit.py resolved keep-both exactly as
predicted. Union verified: 24 on the old base, 29 with #665, 33 with mine, no name
collisions, and the combined suite is green at 62 passed / 586 subtests.

@JarryShaw
JarryShaw merged commit 0f2a2d0 into main Sep 23, 2026
26 checks passed
@JarryShaw
JarryShaw deleted the fix/hip-parameter-padding-651 branch September 23, 2026 02:24
JarryShaw added a commit that referenced this pull request Sep 23, 2026
… unit (#672) (#679)

* `R1CounterParameter.counter` was a `UInt32Field` where
  :rfc:`7401#section-5.2.3` states eight octets twice -- "R1 generation
  counter, 8 bytes" in the diagram, "a 64-bit unsigned integer in network byte
  order" in the prose. The parameter declared the correct `len=12` and packed 12
  octets where the RFC total is 16, four short at `4 (mod 8)`. Now `UInt64Field`,
  so both codes that reach the class are fixed: `R1_Counter` (128) and
  `R1_COUNTER` (129). RFC 5201 section 5.2.3 gives the same layout, so no
  version excused it (#672).
* `_make_param_locator_set` wrote `len` as `sum(Locator.len)`, in the 4-octet
  units RFC 8046 section 4 gives `Locator Length`, where RFC 7401 section 5.2.1's
  `Length` counts bytes -- `4n` declared for `24n` octets of contents. Now
  `sum(8 + Locator.len * 4)`, the octets each locator record occupies (#679).
* `LocatorSetParameter.padding` read the nested `Locator.len` rather than the
  parameter's, because `ListField` packs each nested schema into the enclosing
  packet context and `Schema.pack` opens with `packet.update(self.__dict__)`. The
  shadowed value is 4 for any IPv6 locator, so the old expression appended four
  octets at every locator count. `locator_set_len_callback` now snapshots the
  parameter's `Length` under `LOCATOR_SET_LEN` before the list packs, and
  `locator_set_padding_len` reads that, deferring to `parameter_padding_len` for
  the arithmetic (#679).
* `_read_param_locator_set` reports `parameter_total_len(schema.len)`, the last
  of the 49 record lengths still on the pre-#651 expression (#679).
* `_hip_overrides()` gives both R1_COUNTER codes a non-zero `counter`. A
  zero-valued field cannot discriminate a width defect from a correct one, and
  that is why the RFC-only fixture walk reported nothing on this parameter (#672).

The two had to land together: the padding correction and the `Length` unit
cancelled at homogeneous plain-IPv6 sets, so #651 and #664 excluded this one site
deliberately. Measured across shapes on `f0999858e` and after -- empty 4 -> 8,
plain n=1/2/5 32/56/128 unchanged, SPI n=1 35 -> 32, SPI n=2 63 -> 64, mixed
59/60 -> 56/56 -- every shape is now `11 + Length - (Length + 3) % 8`. The reader
was starved by the same quantity: a one-copy `LOCATOR_SET` of n plain locators
parsed one truncated locator and had its unconsumed 20n octets read as a second,
fabricated parameter; all eight shapes now parse back exactly and repack
identically. An RFC-only walk over `options-internet.pcap` goes from 2 violations
over 91 records to 0 over 92, with a non-zero counter present so the 0 cannot be
concealing a mis-stride. `EXPECTED_FAILURES` is byte-identical at 44 entries:
`hip-parameter/R1_Counter` stays, its cause being the schema registry key (#690),
not the width. Coverage stays 100% on all three HIP modules, 586 -> 623 subtests
in the same scope. pylint, mypy and isort are at exact parity with `f0999858e`.

Fixes #672
Fixes #679
JarryShaw added a commit that referenced this pull request Sep 23, 2026
… unit (#672) (#679)

* `R1CounterParameter.counter` was a `UInt32Field` where
  :rfc:`7401#section-5.2.3` states eight octets twice -- "R1 generation
  counter, 8 bytes" in the diagram, "a 64-bit unsigned integer in network byte
  order" in the prose. The parameter declared the correct `len=12` and packed 12
  octets where the RFC total is 16, four short at `4 (mod 8)`. Now `UInt64Field`,
  so both codes that reach the class are fixed: `R1_Counter` (128) and
  `R1_COUNTER` (129). RFC 5201 section 5.2.3 gives the same layout, so no
  version excused it (#672).
* `_make_param_locator_set` wrote `len` as `sum(Locator.len)`, in the 4-octet
  units RFC 8046 section 4 gives `Locator Length`, where RFC 7401 section 5.2.1's
  `Length` counts bytes -- `4n` declared for `24n` octets of contents. Now
  `sum(8 + Locator.len * 4)`, the octets each locator record occupies (#679).
* `LocatorSetParameter.padding` read the nested `Locator.len` rather than the
  parameter's, because `ListField` packs each nested schema into the enclosing
  packet context and `Schema.pack` opens with `packet.update(self.__dict__)`. The
  shadowed value is 4 for any IPv6 locator, so the old expression appended four
  octets at every locator count. `locator_set_len_callback` now snapshots the
  parameter's `Length` under `LOCATOR_SET_LEN` before the list packs, and
  `locator_set_padding_len` reads that, deferring to `parameter_padding_len` for
  the arithmetic (#679).
* `_read_param_locator_set` reports `parameter_total_len(schema.len)`, the last
  of the 49 record lengths still on the pre-#651 expression (#679).
* `_hip_overrides()` gives both R1_COUNTER codes a non-zero `counter`. A
  zero-valued field cannot discriminate a width defect from a correct one, and
  that is why the RFC-only fixture walk reported nothing on this parameter (#672).

The two had to land together: the padding correction and the `Length` unit
cancelled at homogeneous plain-IPv6 sets, so #651 and #664 excluded this one site
deliberately. Measured across shapes on `f0999858e` and after -- empty 4 -> 8,
plain n=1/2/5 32/56/128 unchanged, SPI n=1 35 -> 32, SPI n=2 63 -> 64, mixed
59/60 -> 56/56 -- every shape is now `11 + Length - (Length + 3) % 8`. The reader
was starved by the same quantity: a one-copy `LOCATOR_SET` of n plain locators
parsed one truncated locator and had its unconsumed 20n octets read as a second,
fabricated parameter; all eight shapes now parse back exactly and repack
identically. An RFC-only walk over `options-internet.pcap` goes from 2 violations
over 91 records to 0 over 92, with a non-zero counter present so the 0 cannot be
concealing a mis-stride. `EXPECTED_FAILURES` is byte-identical at 44 entries:
`hip-parameter/R1_Counter` stays, its cause being the schema registry key (#690),
not the width. Coverage stays 100% on all three HIP modules, 586 -> 623 subtests
in the same scope. pylint, mypy and isort are at exact parity with `f0999858e`.

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

Labels

breaking Alters public API or wire output (apply alongside the type label) fix Pull requests that fix a defect (fix: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HIP parameter padding aligns the contents, not the record, so every parameter pcapkit emits is 4 (mod 8) octets

1 participant