fix(hip,ipv6-route): stop dropping the ENCRYPTED IV and RPL's pack-time cast (#556) - #561
Conversation
c0705bc to
9025729
Compare
|
❌ NEEDS CHANGES Cross-model review (Opus 5; the PR was authored on Sonnet), head Both code fixes are correct, and I independently supplied the fails-without-the-fix proof the description says is missing. The Two changes needed, one substantive:
Also worth relaxing: the provenance caveat says "the fails-without proof this repository normally requires is therefore missing". It is no longer missing — I obtained it for both defects and both exit codes are in the detailed comment below. Nothing else I checked disputes the change. Detail follows. |
Detailed cross-review — #561 @
|
| constructor keyword | warning drawn | IV in packed bytes |
|---|---|---|
cipher=Cipher.AES_128_CBC |
UnknownFieldWarning: 'cipher' is not a valid field name |
absent |
__cipher__=Cipher.AES_128_CBC |
UnknownFieldWarning: '__cipher__' is not a valid field name |
absent |
Identical. Re-spelling the maker's keyword to __cipher__ would have changed nothing, and declaring it as a field would put a cipher ID on the wire that ENCRYPTED does not carry — the ID lives in the preceding HIP_CIPHER parameter. Setting it as a plain attribute so pack()'s packet.update(self.__dict__) carries it into pre_unpack is the only one of the three that works. It is also symmetric with the parse path, where post_process already does self.cipher = packet['__cipher__'].
The defect was worse than the description states. The dropped keyword did not merely omit the IV; it produced an internally inconsistent parameter:
02 81 00 18 | 00 00 00 00 | 'DATA' | 00 00 00 00
^^^^^ ^^^^^
type len = 0x18 = 24
len declares 24 octets of value while only 8 are present. That is not a parameter missing a field, it is an unparseable one.
One thing I checked and found correct rather than suspicious: pre_unpack's new early return does packet.pop('cipher'), and Schema.pack calls it on a dict the caller may own. The pop is load-bearing, not sloppy — with two ENCRYPTED parameters in one packet where only the first carries a resolved cipher, leaving cipher in the shared context would silently hijack the second one's sibling lookup. Popping it is what keeps them independent.
2. Defect 2's test does exercise pack()
I was asked to confirm this specifically, since constructing an RPL schema does not reproduce the fault. It does: the test calls bytes(rpl_schema) and bytes(header), and Schema.__bytes__ calls self.pack() when __updated__ is set, which __update__ sets at construction. Both the direct schema level and the public make entry point are covered.
3. Fails-without-the-fix proof — supplied, since the description says it is missing
Reverted each fix in isolation, with the PR's tests in place. Restored from byte-identical copies afterwards and confirmed git status clean and md5sums matching in both cases.
| tree | test | exit code (from file) | failure |
|---|---|---|---|
| PR head, both fixes | both affected test files | 0 — 75 passed, 146 subtests | — |
HIP fix reverted to main |
-k encrypted_preserves_iv |
1 | UnknownFieldWarning("'cipher' is not a valid field name") + ProtocolWarning('HIP: [ParamNo 641] missing HIP_CIPHER parameter') |
RPL fix reverted to main |
-k rpl_packs_a_multi_address |
1 | ValueError: [b' \x01\r\xb8…\x01', b' \x01\r\xb8…\x02'] does not appear to be an IPv4 or IPv6 address |
Both defects are real and both tests genuinely pin them.
Minor: the HIP test's first assertion is assertEqual(caught, []), so in the reverted state it fails on the warning and never reaches assertIn(iv, packed) — the assertion that names the actual harm. Asserting the IV first would make the failure message say "the IV is gone" rather than "a warning fired". Not blocking; I confirmed the IV loss independently above.
4. The ProtocolError was unmasked, not introduced — proven
This is the part of the PR I was most sceptical of, and it holds up.
| tree | exit code (from file) | result |
|---|---|---|
main @ 8cfd6ab01 |
0 | 322 subtests passed |
main + only the RPL schema fix (HIP untouched) |
1 | exactly one SUBFAILED(case='ipv6-route-type/RPL_Source_Route_Header'), detail ProtocolError: IPv6-Route: [TypeNo 3] invalid format |
PR head 9025729f2 (full, with the re-point) |
0 | 6 passed, 358 subtests |
The middle row is the decisive one: 18 lines in one file, no HIP involvement, and the new failure appears. And the guard itself is untouched by this PR — if header.length % 16 != 0: is at line 612 and its raise at 613 on main and on the PR head alike, same line numbers. So the entry must be re-pointed rather than deleted, exactly as claimed.
The guard is genuinely broken, independently confirmed. Instrumenting it for the recorded case gives header.length (Hdr Ext Len) = 5, so 5 % 16 = 5 and it raises. Two lines below the guard, ipv6_route_header_length(header.length) converts from 8-octet units — which settles the unit question from the code itself. A % 16 != 0 test on a value in 8-octet units accepts only Hdr Ext Len ∈ {0, 16, 32, …}, i.e. it rejects very nearly every real RPL header. Same class of confusion as #487.
5. The re-pointed entry is correct in every field I could check
status='CONSTRUCT'— correct, and not obviously so. The raise comes from a_read_*method, butSTATUSES' own docstring records thatProtocolBase.__post_init__packs and then unpacks, "so a_read_*fault on a perfectly good pack also lands here". The harness did reportCONSTRUCT.fragment='IPv6-Route: [TypeNo 3] invalid format'— correct and specific, satisfyingGap.fragment's requirement of the alias plus the bracketed code, and not the bare'invalid format'form the docstring warns occurs 205 times. I verified it pins one site:Routing.RPL_Source_Route_Header == 3, and the three sites in this file that print[TypeNo …] invalid formatsit in_read_data_type_src(511),_read_data_type_2(559) and_read_data_type_rpl(612/613) — each reachable only for its own routing type, so[TypeNo 3]can only come from 613's site.defectline:612— correct. 612 is the condition (the cause), 613 theraise, matching the convention in neighbouring entries, which name the cause (e.g. the TCP entry citestcp.py:2506 -- _make_mode_timeout sets length=3).- It also fixes a pre-existing error. The old entry cited
schema/internet/ipv6_route.py:156, which isSourceRoute.ip— a different class in a different file. The real defectivecastwas at line 208 (post_processbegins at 198). That entry had been wrong by ~52 lines and the wrong file; the re-point corrects both.
6. The one substantive problem: "well-formed" is false
The new defect string reads "the reader rejects a well-formed header". Measured, on the exact case the entry records — the harness builds it from {'ip': ['2001:db8::1', '2001:db8::2']} with next=TransType.UDP, which is precisely what I reproduced (the leading 11 octet matches):
packed: 11 05 03 00 | 00 00 | 00 00 00 | <32 octets of addresses>
next=UDP cmpr_i cmpr_e pad(3)
Hdr Ext Len = 5
actual packed octets = 41
octets declared by Hdr Ext Len = 8 + 8*5 = 48 -> inconsistent by 7
Per-field widths from the schema's own buffer: cmpr_i 1, cmpr_e 1, pad 3, addresses 32, padding 0. The fixed (non-address) area is therefore 5 octets. RFC 6554 — as drawn in _read_data_type_rpl's own docstring — lays that area out as a single 32-bit row:
| CmprI | CmprE | Pad | Reserved |
CmprI and CmprE are 4 bits each and share one octet; the area is 4 octets, not 5. A spec-correct two-address RPL header is 4 + 4 + 32 = 40 octets, i.e. Hdr Ext Len = (40-8)/8 = 4. The schema emits one octet too many, which is also why the declared length cannot be made consistent.
So there is a third pre-existing defect behind the % 16 guard, and the header being rejected is not well-formed. This matters because the defect field is, in the table's own words, "the field that makes the entry worth keeping rather than just silencing": as written it directs the next person to fix % 16 and implies that will close the cycle. It will not. Please reword to say what is actually true — the guard is wrong and the header it is handed is malformed independently — and file the octet-width defect so the entry can cite it.
I am flagging this as a prose/accuracy problem in the entry, not as a demand that this PR fix the RPL round trip. Leaving that unfixed here is the right call and the entry says why convincingly.
7. Other checks
- Changelog is generated, not hand-written.
python util/changelog_md.py --check→ exit 0, "CHANGELOG.md is in step with docs/source/changelog/1.5.0.rst". This is the check CI runs asChangelog drift. - Commit shape — one commit,
9025729f2, on top of currentmain8cfd6ab01, no merge commit. (The earlier headc0705bc84was a merge commit despite the description claiming one rebased commit; the force-push fixed that.) - Scope —
pcapkit/protocols/internet/ipv6_route.pyis in the diff as a comment rewrite only; it does not touch the guard. Consistent with the description.
Could not verify
- CI. Reached deliberately on local evidence only, per my brief; I did not wait on GitHub Actions and make no claim about its tally.
- Behaviour on any interpreter other than CPython 3.14.7. Everything above is single-version.
- Real-capture RPL behaviour. Like the PR, I have no real RPL capture. My octet-width finding is derived from the module's own RFC 6554 diagram and the schema's packed field widths, not from wire data.
- Whether the HIP fix is correct for a multi-
ENCRYPTED-parameter packet parsed from a real HIP capture. I reasoned about thepopmaking siblings independent and believe it is right, but I exercised it only throughmake, not against a capture containing twoENCRYPTEDparameters with different ciphers.
…me cast (#556) Two small defects flagged in review this release and never filed: - HIP._make_param_encrypted passed cipher= to Schema_EncryptedParameter, a keyword the schema does not declare. __cipher__ is a pre_unpack packet-context key, not a field either spelling reaches -- both draw UnknownFieldWarning and are dropped -- so every AES-cipher ENCRYPTED parameter built through make packed without its IV. Fix: set the resolved cipher as a plain attribute after construction; pack()'s packet.update(self.__dict__) carries it into pre_unpack, which now trusts it ahead of its own HIP_CIPHER sibling lookup (a standalone make call has no options list for that lookup to search anyway). - RPL.post_process cast self.addresses to bytes unconditionally, but it runs on every Schema.pack, not only after a parse, and a schema built via make (IPv6_Route._make_data_type_rpl) still holds addresses as list[bytes]. Slicing and re-joining that list as the concatenated buffer a parse produces raised at pack time. Fix: skip the SRH decompression when addresses is not bytes -- nothing reads .ip on the make path. Fixing the second defect exposes two pre-existing ones immediately behind it, neither fixed here: IPv6_Route._read_data_type_rpl's own `header.length % 16 != 0` guard (ipv6_route.py:612) treats Hdr Ext Len as octets rather than 8-octet units, the same confusion #487 fixed for Source Route and Type 2; and the header it is judging is not well-formed either way, since RPL's fixed area (cmpr_i + cmpr_e + pad) packs to 5 octets against the 4 RFC 6554 specifies, filed as #564. RPL addresses are also variable-length under cmpr_i/cmpr_e, so no fixed bound is obviously right even once both are fixed, and nothing has been checked against a real RPL capture. The RPL_Source_Route_Header entry in tests/protocols/test_option_roundtrip_unit.py's EXPECTED_FAILURES is re-pointed at these, not deleted, since the case still does not construct -- just for different, now-documented reasons. New regression tests for both original defects, each shown to fail without its fix. Build and full test suite pass.
9025729 to
d429404
Compare
|
✅ GOOD TO MERGE Re-review after the force-push, head Both items from my earlier
Re-verified on this head:
And importantly: The one remaining nit from my earlier comment is unchanged and still not blocking: the HIP test asserts Nothing else to raise. Good work on #564 — that was the right call over folding a speculative RPL fix into this PR. |
…tes (#540) Follow-up to #508/#539/#552: bool is an int subclass, so a bare ipaddress.IPv4Address/IPv6Address/ip_address call in a _make_* helper silently laundered True/False into 0.0.0.1/::1 instead of raising. Four sites were deliberately left out of #539 because their files were owned by other work at the time; all four are now routed through the existing parse_ip_address helper, the same pattern #539 and #552 used. - ARP._make_proto_resolve (pcapkit/protocols/link/arp.py): addr=True packed as 00000001 (IPv4) or ::1 (IPv6) with no exception. - IPv6_Route._make_data_type_rpl (pcapkit/protocols/internet/ipv6_route.py): worse than a packed-address defect, since cmpr_i/cmpr_e are derived from the laundered value -- ip=[True] packed with cmpr_e=0 and an address of 00000001 instead of raising. - IPv6_Route.make's dst parameter: the most reachable of the four, on the public make() entry point; dst=True converted to ::1 silently. - OSPF._make_id_numbers: latent, no production caller today, fixed anyway so it does not resurface the defect the moment one is added. ARP and OSPF use self.__class__.__name__ rather than self.alias in the FieldValueError message, because their alias properties read state (_acnm, _version) that read() only assigns -- unavailable to a construction-only instance that never went through read(). Beyond bool rejection, pinning version=6 on the two IPv6_Route sites is a second, smaller behaviour change: both previously converted a plain integer through the bare, family-inferring ipaddress.ip_address, so ip=[258] packed a 4-octet IPv4 address (0.0.1.2) inside an IPv6-only header; it now packs the 16-octet IPv6 form (::102) instead, which is what an IPv6-only header should hold regardless of what an int happens to fit as an IPv4 address. Overlaps PR #561 (open, unmerged) in ipv6_route.py, which touches _read_data_type_rpl; this change stays inside make()/_make_data_type_rpl so the conflict on merge should be trivial. Four new tests, each shown to fail without its fix (FieldValueError not raised). Full unit tier green: 1120 passed, 8 skipped, 2673 subtests passed. CHANGELOG.md regenerated via util/changelog_md.py from docs/source/changelog/1.5.0.rst.
…tes (#540) (#568) Follow-up to #508/#539/#552: bool is an int subclass, so a bare ipaddress.IPv4Address/IPv6Address/ip_address call in a _make_* helper silently laundered True/False into 0.0.0.1/::1 instead of raising. Four sites were deliberately left out of #539 because their files were owned by other work at the time; all four are now routed through the existing parse_ip_address helper, the same pattern #539 and #552 used. - ARP._make_proto_resolve (pcapkit/protocols/link/arp.py): addr=True packed as 00000001 (IPv4) or ::1 (IPv6) with no exception. - IPv6_Route._make_data_type_rpl (pcapkit/protocols/internet/ipv6_route.py): worse than a packed-address defect, since cmpr_i/cmpr_e are derived from the laundered value -- ip=[True] packed with cmpr_e=0 and an address of 00000001 instead of raising. - IPv6_Route.make's dst parameter: the most reachable of the four, on the public make() entry point; dst=True converted to ::1 silently. - OSPF._make_id_numbers: latent, no production caller today, fixed anyway so it does not resurface the defect the moment one is added. ARP and OSPF use self.__class__.__name__ rather than self.alias in the FieldValueError message, because their alias properties read state (_acnm, _version) that read() only assigns -- unavailable to a construction-only instance that never went through read(). Beyond bool rejection, pinning version=6 on the two IPv6_Route sites is a second, smaller behaviour change: both previously converted a plain integer through the bare, family-inferring ipaddress.ip_address, so ip=[258] packed a 4-octet IPv4 address (0.0.1.2) inside an IPv6-only header; it now packs the 16-octet IPv6 form (::102) instead, which is what an IPv6-only header should hold regardless of what an int happens to fit as an IPv4 address. Overlaps PR #561 (open, unmerged) in ipv6_route.py, which touches _read_data_type_rpl; this change stays inside make()/_make_data_type_rpl so the conflict on merge should be trivial. Four new tests, each shown to fail without its fix (FieldValueError not raised). Full unit tier green: 1120 passed, 8 skipped, 2673 subtests passed. CHANGELOG.md regenerated via util/changelog_md.py from docs/source/changelog/1.5.0.rst.
Closes #556. Two small defects flagged in review earlier this release and never filed.
1.
HIP._make_param_encrypteddropped the IV.hip.py:3533passedcipher=toSchema_EncryptedParameter, a keyword the schema does not declare. Neither spelling lands:__cipher__is apre_unpackpacket-context key, not a field, socipher=and__cipher__both drawUnknownFieldWarningand are dropped. Every AES-cipherENCRYPTEDparameter built throughmaketherefore packed without its IV. Flagged in #430's review and re-flagged in #434 as "carried across unchanged"; it survived because the schema warns rather than raising.The fix sets the resolved cipher as a plain attribute after construction, so
pack()'spacket.update(self.__dict__)carries it intopre_unpack, which now trusts it ahead of its ownHIP_CIPHERsibling lookup — a standalonemakecall has no options list for that lookup to search anyway.2.
RPL.post_processcast a list as bytes.schema/internet/ipv6_route.py:207castself.addressestobytesunconditionally, but it runs on everySchema.pack, not only after a parse, and a schema built viamake(IPv6_Route._make_data_type_rpl) still holdsaddressesaslist[bytes]. Slicing and re-joining that list as the concatenated buffer a parse produces raised at pack time. Reproduced during #489's review and left with only a code comment. The fix skips SRH decompression whenaddressesis notbytes.Both defects share the shape that made them invisible: the schema layer warns where it could raise. Whether that should be fatal during
makeis deliberately not decided here — it has an eleven-site precondition in_make_mptcp_*and belongs in its own issue.Fixing defect 2 unmasks two pre-existing defects rather than introducing one. With
RPL.post_processno longer raising, the RPL round-trip case now reachesIPv6_Route._read_data_type_rpl's own length guard,pcapkit/protocols/internet/ipv6_route.py:612(if header.length % 16 != 0:), and fails there instead:ProtocolError: IPv6-Route: [TypeNo 3] invalid format. That guard treatsheader.length(Hdr Ext Len) as octets when it is actually in 8-octet units — the same unit confusion #487 fixed for Source Route and Type 2. But the header the guard is judging is not well-formed either way: RPL's fixed area (cmpr_i+cmpr_e+pad) packs to 5 octets against the 4 RFC 6554 specifies (echoed in the method's own docstring diagram) — measured, it constructs to 41 octets against the 48 its ownHdr Ext Lenof 5 declares. Filed as #564. Neither is fixed here: RPL addresses are also variable-length undercmpr_i/cmpr_e, so no fixed bound is obviously right even once the units and the field widths are both correct, and nothing has been checked against a real RPL capture.tests/protocols/test_option_roundtrip_unit.py'sEXPECTED_FAILURESentry foripv6-route-type/RPL_Source_Route_Headeris re-pointed at these, not deleted, since the case still does not construct — only the reasons changed, and the entry now names both.9 files, +190/-18, one commit, on top of
8cfd6ab01.Provenance, verified rather than asserted. Earlier drafts of this PR body said the test evidence was missing because the authoring session was killed by a host OOM (from this branch's own
coverage run -m pytest tests, which reached 36.9 GB RSS). That is no longer true. I reproduced both halves of the "unmasked, not introduced" claim directly, using plainpytest(nevercoverage run -m pytest tests) and reading exit codes from files rather than summary lines. A cross-review on a separate model reviewed this PR independently and reports having reproduced the same unmasking and the #564 field-width measurement on its own, with exit codes in its own comment on this PR:origin/mainat8cfd6ab01(before this PR's fix),pytest tests/protocols/test_option_roundtrip_unit.py -k round_trip_is_identityexits 0 — the RPL case fails there exactly as the oldEXPECTED_FAILURESentry recorded (does not appear to be an IPv4 or IPv6 address).EXPECTED_FAILURESentry still in place, the same run exits 1 with exactly oneSUBFAILED(ipv6-route-type/RPL_Source_Route_Header, 321 of 322 subtests passing), reproducing the CI failure verbatim:AssertionError: 'does not appear to be an IPv4 or IPv6 address' not found in 'ProtocolError: IPv6-Route: [TypeNo 3] invalid format'.EXPECTED_FAILURESentry re-pointed as described above, the same run exits 0 again, with all 322 subtests passing.pytest -q --ignore=tests/integration --ignore-glob='*_runtime.py' --ignore-glob='*_regression.py') exits 0:1121 passed, 5 skipped, 256 warnings, 2704 subtests passed.IPv6_Route.make()and packing it (bypassing the read-back guard) confirms the RPL schema's fixed area is 5 octets where RFC 6554 gives 4, so a built header is wider than its own Hdr Ext Len #564 numbers above:Hdr Ext Lenfield = 5, actual packed total = 41 octets,(Hdr Ext Len + 1) * 8= 48 octets.EXPECTED_FAILUREShad 54 entries before this PR and has 54 after — this re-points one entry, it does not add or delete any.