fix(hopopt,ipv6-opts): size the ILNP nonce with a real ceiling, not a floored one (#601) - #607
Conversation
… floored one (#601) The ILNP Nonce option builders sized the option with `math.ceil(nonce.bit_length() // 8)` at hopopt.py:1889 and ipv6_opts.py:1892. `//` floors, and `math.ceil` of an `int` is a no-op, so the ceiling was never taken. The nonce is packed by a `NumberField` whose width *is* that declared `len` (schema/internet/hopopt.py:722, ipv6_opts.py:727), so an under-declared length did not merely mis-state the option -- it silently truncated the nonce on the wire, with nothing raised. - Small nonces are the worst case, not boundary values. Any nonce below 256 was declared as *zero* octets and dropped from the packet: measured, `nonce=9` packed to `b'\x8b\x00'` and parsed back as `0`, and `nonce=256` and `nonce=65536` truncated to `0` as well. Only a bit length that is an exact multiple of eight gave the right answer. - Fixed to `max(1, math.ceil(nonce.bit_length() / 8))`, the form already used at five other sizing sites -- hip.py:3156, :3334, :4289, :4315 and mh.py:7947. - The one-octet floor is mh.py's convention and is load-bearing here because `nonce` defaults to `0`, whose bit length is `0`. Without it the default argument builds an ILNP Nonce option carrying no Nonce Value field at all, collapsing "the nonce is 0" into "there is no nonce" when RFC 6744 gives the option that field. Value round-trip is unaffected either way, so this is structural rather than a data fix, and it is called out as a deliberate addition rather than folded in silently. - The read path was never affected: it takes the width from the `len` octet on the wire rather than recomputing it. New cases in tests/protocols/internet/test_ipv6_extension_unit.py, one test per protocol, assert the declared length, the exact packed octets and the construct-pack-parse cycle over ten nonces. Reverting hopopt.py alone fails test_hopopt_... with 8 SUBFAILED subtests (`0 != 1`, `1 != 2`, `2 != 3`, `4 != 5`) while test_ipv6_opts_... passes, and reverting ipv6_opts.py alone mirrors it exactly -- so each site is pinned by its own test. The two byte-aligned controls, 255 and 0xFFFFFF, pass either way. The suite missed this because the only ILNP nonce it exercised was `0xFFFFFF` (examples/generators/options.py:537), bit length 24, precisely where floor division and the ceiling agree -- the same blind spot that hid the identical typo in numbers.py. There is no ILNP entry in EXPECTED_FAILURES (45 entries, confirmed by importing it), and none starts passing, so that file is untouched. The new table guards itself: it asserts at least six of its own values stay non-byte-aligned and one stays below 256. tests/protocols/internet/ 221 passed, 718 subtests, exit 0. Coverage of the two modules 99% statement and branch, the one uncovered line in each being a pre-existing gap in `_make_opt_pad` from #398. Statement coverage of the changed line was already 100% before this change, which is exactly why the defect survived; what the new tests add is assertions on the length it produces. `python util/changelog_md.py --check` exits 0. Fixes #601
|
✅ GOOD TO MERGE — head |
Cross-review appendix — PR #607Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head
|
The ILNP Nonce option builders sized the option with
math.ceil(nonce.bit_length() // 8)athopopt.py:1889andipv6_opts.py:1892.//floors, andmath.ceilof anintis a no-op, so the ceiling was never taken.This is silent wire corruption, not a cosmetic length field
nonceis packed by aNumberFieldwhose width is that declaredlen(pcapkit/protocols/schema/internet/hopopt.py:722,ipv6_opts.py:727), so under-declaring the length truncates the nonce on the wire with nothing raised. Measured on the unfixed tree, construct then parse back:len9b'\x8b\x00'0— nonce gone entirely42b'\x8b\x00'0127b'\x8b\x00'0255b'\x8b\x01\xff'255— control256b'\x8b\x01\x00'0— truncated65536b'\x8b\x02\x00\x00'0— truncated0xFFFFFFb'\x8b\x03\xff\xff\xff'0xFFFFFF— controlSmall nonces are the worst case rather than boundary values: anything below 256 was declared as zero octets and dropped from the packet. Only an exact multiple of eight gave the right answer.
The fix
max(1, math.ceil(nonce.bit_length() / 8))at both sites — the form already used at five other sizing sites:hip.py:3156,:3334,:4289,:4315andmh.py:7947.The one-octet floor is
mh.py's convention (the reasoning is spelled out in the comment atmh.py:7936) and is load-bearing here becausenoncedefaults to0, whose bit length is0: without it,_make_opt_ilnp(code)with no nonce builds an ILNP Nonce option carrying no Nonce Value field at all, collapsing "the nonce is 0" into "there is no nonce" when RFC 6744 gives the option that field. Value round-trip is unaffected either way (0→ zero octets →0), so this part is structural rather than a data fix. It is called out here rather than folded in silently, and is easy to drop if you would rather keep the change to the division alone.The read path was never affected: it takes the width from the
lenoctet on the wire rather than recomputing it.Tests, proven failing then passing, per site
One test per protocol, so each site is pinned independently. Reverting
hopopt.pyalone:Reverting
ipv6_opts.pyalone mirrors it exactly (9 failed, thehopopttest passing), with the assertion deltas being precisely the floor-vs-ceiling gap:With both fixes in place,
2 passed. The two byte-aligned controls,255and0xFFFFFF, pass either way.Why the suite missed it
The only ILNP nonce the suite exercised was
0xFFFFFF(examples/generators/options.py:537) — bit length 24, an exact multiple of eight, precisely where floor division and the ceiling agree. Same blind spot that hid the identical typo innumbers.py(#599/#600).So every new case bar the two controls has a bit length that is not a multiple of eight, several below 256, and the table guards itself — the test asserts at least six of its own values stay non-byte-aligned and one stays below 256, so rounding them off to convenient constants later cannot quietly disarm the regression.
There is no ILNP entry in
EXPECTED_FAILURES(45 entries, confirmed by importing it rather than grepping) and none starts passing, so that file is untouched.Verification
tests/protocols/internet/— 221 passed, 718 subtests, exit 0tests/protocols/test_option_roundtrip_unit.py+test_option_coverage_runtime.py— 9 passed, 378 subtests, exit 0coverage run --include=..., never pytest-cov). The single uncovered line in each is a pre-existing gap in_make_opt_padfrom ipv6/mh: fix option padding, and with it construction, which was wholly broken #398. Statement coverage of the changed line was already 100% before this change — which is exactly why the defect survived; what the new tests add is assertions on the length it produces.python util/changelog_md.py --checkexits 0.493020f83; one commit. The changelog conflict against fix(corekit): size the width repair with a real ceiling, not a floored one (#599) #600's bullets was resolved keeping all bullets, mine last, andCHANGELOG.mdregenerated rather than hand-merged.Found but deliberately not fixed:
hip.py:3200is also wrong#601 recorded
hip.py:3200'smath.ceil(... / 4)as possibly a deliberate nibble count. It is not — it is a defect, buthip.pyis outside this change and it is reported rather than folded in.The
/ 4is an invalid shorthand for "two fields ofceil(bits/8)octets each".SolutionParametergivesrandomandsolutiona width of(pkt['len'] - 4) // 2each (pcapkit/protocols/schema/internet/hip.py:454,456), and the reader's own comment athip.py:1090reads# Length (schema.len) = 4 + RHASH_len / 4— valid only because the realRHASH_lenis a fixed multiple of 8. Substituting an arbitrarybit_length()for it breaks the identity, sinceceil(x/4) != 2*ceil(x/8)in general.The builder emits parameters its own reader rejects. Measured:
len=7makes(schema.len - 4) % 2 != 0, whichhip.py:1083-1084raises on. The correct expression is4 + 2 * math.ceil(max(random.bit_length(), solution.bit_length()) / 8). Worth its own issue.Two further things noticed in
hip.pyand not investigated:_make_param_solution/_make_param_puzzlerecompute widths frombit_length()when re-serializing an already-parsed parameter, so leading zero octets in the original encoding are lost on a parse-then-rebuild; and the readers compute padding fromschema.len % 8rather than from the total record length including the 4-octet header.Fixes #601