netprotocols 2.7.0__tar.gz → 2.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. {netprotocols-2.7.0 → netprotocols-2.8.0}/.gitignore +2 -1
  2. {netprotocols-2.7.0 → netprotocols-2.8.0}/ARCHITECTURE.md +124 -19
  3. {netprotocols-2.7.0 → netprotocols-2.8.0}/CHANGELOG.md +19 -1
  4. {netprotocols-2.7.0 → netprotocols-2.8.0}/CONTRIBUTING.md +10 -1
  5. {netprotocols-2.7.0 → netprotocols-2.8.0}/PKG-INFO +29 -20
  6. {netprotocols-2.7.0 → netprotocols-2.8.0}/README.md +28 -19
  7. {netprotocols-2.7.0 → netprotocols-2.8.0}/SECURITY.md +7 -0
  8. {netprotocols-2.7.0 → netprotocols-2.8.0}/docs/CLAIMS.md +540 -86
  9. {netprotocols-2.7.0 → netprotocols-2.8.0}/pyproject.toml +2 -2
  10. netprotocols-2.8.0/scripts/benchmark_encode.py +133 -0
  11. netprotocols-2.8.0/scripts/capture_fixtures_tls.sh +565 -0
  12. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/check_fixtures.py +201 -6
  13. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/extract_http_streams.py +4 -1
  14. netprotocols-2.8.0/scripts/survey_protocols.py +783 -0
  15. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/__init__.py +13 -1
  16. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_base.py +6 -4
  17. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_defaults.py +9 -0
  18. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/__init__.py +12 -0
  19. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/stun.py +6 -4
  20. netprotocols-2.8.0/src/netprotocols/layer7/tls.py +1354 -0
  21. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/walk.py +34 -17
  22. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/conftest.py +32 -0
  23. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/MANIFEST.md +86 -8
  24. netprotocols-2.8.0/tests/fixtures/tls/tls_session.pcap +0 -0
  25. netprotocols-2.8.0/tests/fixtures/tls.pcap +0 -0
  26. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fuzz/fuzz_decode_frame.py +8 -4
  27. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/strategies.py +58 -0
  28. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_bgp.py +28 -0
  29. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_contract.py +2 -0
  30. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_corpus.py +53 -1
  31. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_dns.py +2 -2
  32. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_docs.py +1 -0
  33. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_fuzz.py +335 -1
  34. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_stun.py +3 -2
  35. netprotocols-2.8.0/tests/test_tls.py +2525 -0
  36. {netprotocols-2.7.0 → netprotocols-2.8.0}/uv.lock +1 -1
  37. netprotocols-2.7.0/scripts/benchmark_encode.py +0 -107
  38. netprotocols-2.7.0/tests/fixtures/staging/ah.pcap +0 -0
  39. netprotocols-2.7.0/tests/fixtures/staging/arp_exchange.pcap +0 -0
  40. netprotocols-2.7.0/tests/fixtures/staging/bgp/bgp_session.pcap +0 -0
  41. netprotocols-2.7.0/tests/fixtures/staging/bgp.pcap +0 -0
  42. netprotocols-2.7.0/tests/fixtures/staging/dhcp.pcap +0 -0
  43. netprotocols-2.7.0/tests/fixtures/staging/dns_tcp.pcap +0 -0
  44. netprotocols-2.7.0/tests/fixtures/staging/esp.pcap +0 -0
  45. netprotocols-2.7.0/tests/fixtures/staging/gre.pcap +0 -0
  46. netprotocols-2.7.0/tests/fixtures/staging/hsrp.pcap +0 -0
  47. netprotocols-2.7.0/tests/fixtures/staging/http.pcap +0 -0
  48. netprotocols-2.7.0/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  49. netprotocols-2.7.0/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  50. netprotocols-2.7.0/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  51. netprotocols-2.7.0/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  52. netprotocols-2.7.0/tests/fixtures/staging/igmp.pcap +0 -0
  53. netprotocols-2.7.0/tests/fixtures/staging/ipip.pcap +0 -0
  54. netprotocols-2.7.0/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  55. netprotocols-2.7.0/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
  56. netprotocols-2.7.0/tests/fixtures/staging/ipv6_mld.pcap +0 -0
  57. netprotocols-2.7.0/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  58. netprotocols-2.7.0/tests/fixtures/staging/llc.pcap +0 -0
  59. netprotocols-2.7.0/tests/fixtures/staging/lldp.pcap +0 -0
  60. netprotocols-2.7.0/tests/fixtures/staging/mpls/mpls_snaplen.pcap +0 -0
  61. netprotocols-2.7.0/tests/fixtures/staging/mpls.pcap +0 -0
  62. netprotocols-2.7.0/tests/fixtures/staging/ntp.pcap +0 -0
  63. netprotocols-2.7.0/tests/fixtures/staging/ospf.pcap +0 -0
  64. netprotocols-2.7.0/tests/fixtures/staging/pim.pcap +0 -0
  65. netprotocols-2.7.0/tests/fixtures/staging/radius.pcap +0 -0
  66. netprotocols-2.7.0/tests/fixtures/staging/rip.pcap +0 -0
  67. netprotocols-2.7.0/tests/fixtures/staging/sctp.pcap +0 -0
  68. netprotocols-2.7.0/tests/fixtures/stun/stun_rfc3489.pcap +0 -0
  69. netprotocols-2.7.0/tests/fixtures/stun.pcap +0 -0
  70. netprotocols-2.7.0/tests/fixtures/tcp_http.pcap +0 -0
  71. netprotocols-2.7.0/tests/fixtures/tcp_https.pcap +0 -0
  72. netprotocols-2.7.0/tests/fixtures/tftp.pcap +0 -0
  73. netprotocols-2.7.0/tests/fixtures/udp_dns.pcap +0 -0
  74. netprotocols-2.7.0/tests/fixtures/vlan_icmp.pcap +0 -0
  75. netprotocols-2.7.0/tests/fixtures/vrrp.pcap +0 -0
  76. netprotocols-2.7.0/tests/fixtures/vxlan.pcap +0 -0
  77. {netprotocols-2.7.0 → netprotocols-2.8.0}/.claude/skills/steward/SKILL.md +0 -0
  78. {netprotocols-2.7.0 → netprotocols-2.8.0}/.gitattributes +0 -0
  79. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/CODEOWNERS +0 -0
  80. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  81. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  82. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
  83. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/dependabot.yml +0 -0
  84. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/pull_request_template.md +0 -0
  85. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/ci.yml +0 -0
  86. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/codeql.yml +0 -0
  87. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/docs-lint.yml +0 -0
  88. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/fuzz.yml +0 -0
  89. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/mutation.yml +0 -0
  90. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/pr-title-lint.yml +0 -0
  91. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/release.yml +0 -0
  92. {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/scorecard.yml +0 -0
  93. {netprotocols-2.7.0 → netprotocols-2.8.0}/LICENSE +0 -0
  94. {netprotocols-2.7.0 → netprotocols-2.8.0}/benchmarks/baseline.json +0 -0
  95. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/_pcap.py +0 -0
  96. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/benchmark.py +0 -0
  97. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/benchmark_import.py +0 -0
  98. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures.sh +0 -0
  99. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_bgp.sh +0 -0
  100. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_dhcp.sh +0 -0
  101. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_dns_tcp.sh +0 -0
  102. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_gre.sh +0 -0
  103. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_hsrp.sh +0 -0
  104. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_http.sh +0 -0
  105. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_igmp.sh +0 -0
  106. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ipip.sh +0 -0
  107. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ipsec.sh +0 -0
  108. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_llc.sh +0 -0
  109. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_lldp.sh +0 -0
  110. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_mpls.sh +0 -0
  111. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ntp.sh +0 -0
  112. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ospf.sh +0 -0
  113. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_pim.sh +0 -0
  114. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_radius.sh +0 -0
  115. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_rip.sh +0 -0
  116. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_sctp.sh +0 -0
  117. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_stun.sh +0 -0
  118. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_supplement.sh +0 -0
  119. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_tftp.sh +0 -0
  120. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_vlan.sh +0 -0
  121. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_vrrp.sh +0 -0
  122. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_vxlan.sh +0 -0
  123. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/check_mutmut_survivors.py +0 -0
  124. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/filter_codeql_sarif.py +0 -0
  125. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/ipsec_fixture_packets.py +0 -0
  126. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/llc_fixture_frames.py +0 -0
  127. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/mpls_fixture_packets.py +0 -0
  128. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/mutmut_survivors.txt +0 -0
  129. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/check_in_pyodide.py +0 -0
  130. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/package-lock.json +0 -0
  131. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/package.json +0 -0
  132. {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/run_in_pyodide.mjs +0 -0
  133. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_enums.py +0 -0
  134. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_tlv.py +0 -0
  135. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/checksum.py +0 -0
  136. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/flow.py +0 -0
  137. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/__init__.py +0 -0
  138. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/arp.py +0 -0
  139. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/cdp.py +0 -0
  140. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/ethernet.py +0 -0
  141. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/llc.py +0 -0
  142. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/lldp.py +0 -0
  143. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/mpls.py +0 -0
  144. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/stp.py +0 -0
  145. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/vlan.py +0 -0
  146. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/__init__.py +0 -0
  147. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ah.py +0 -0
  148. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/esp.py +0 -0
  149. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/gre.py +0 -0
  150. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/icmp.py +0 -0
  151. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/igmp.py +0 -0
  152. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ip.py +0 -0
  153. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ipv6_ext.py +0 -0
  154. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ospf.py +0 -0
  155. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/pim.py +0 -0
  156. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/vrrp.py +0 -0
  157. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/__init__.py +0 -0
  158. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/_ports.py +0 -0
  159. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/sctp.py +0 -0
  160. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/tcp.py +0 -0
  161. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/udp.py +0 -0
  162. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/bgp.py +0 -0
  163. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/dhcp.py +0 -0
  164. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/dns.py +0 -0
  165. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/hsrp.py +0 -0
  166. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/http.py +0 -0
  167. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/ntp.py +0 -0
  168. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/radius.py +0 -0
  169. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/rip.py +0 -0
  170. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/tftp.py +0 -0
  171. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/vxlan.py +0 -0
  172. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/packet.py +0 -0
  173. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/pcap.py +0 -0
  174. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/py.typed +0 -0
  175. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/registry.py +0 -0
  176. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/__init__.py +0 -0
  177. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/exceptions.py +0 -0
  178. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/ipv4.py +0 -0
  179. {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/mac.py +0 -0
  180. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ah.pcap +0 -0
  181. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/arp_exchange.pcap +0 -0
  182. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/bgp/bgp_session.pcap +0 -0
  183. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/bgp.pcap +0 -0
  184. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/codeql/python.sarif +0 -0
  185. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/dhcp.pcap +0 -0
  186. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/dns_tcp.pcap +0 -0
  187. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/esp.pcap +0 -0
  188. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/gre.pcap +0 -0
  189. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/hsrp.pcap +0 -0
  190. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/01.request +0 -0
  191. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/01.response +0 -0
  192. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/02.request +0 -0
  193. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/02.response +0 -0
  194. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/03.request +0 -0
  195. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/03.response +0 -0
  196. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/04.request +0 -0
  197. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/04.response +0 -0
  198. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/05.request +0 -0
  199. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/05.response +0 -0
  200. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/06.request +0 -0
  201. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/06.response +0 -0
  202. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/07.request +0 -0
  203. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/07.response +0 -0
  204. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/http.pcap +0 -0
  205. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  206. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv4_external.pcap +0 -0
  207. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  208. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  209. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/igmp.pcap +0 -0
  210. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipip.pcap +0 -0
  211. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
  212. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
  213. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv6_mld.pcap +0 -0
  214. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  215. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/llc.pcap +0 -0
  216. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/lldp.pcap +0 -0
  217. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/mpls/mpls_snaplen.pcap +0 -0
  218. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/mpls.pcap +0 -0
  219. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ntp.pcap +0 -0
  220. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ospf.pcap +0 -0
  221. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/pim.pcap +0 -0
  222. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/radius.pcap +0 -0
  223. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/rip.pcap +0 -0
  224. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/sctp.pcap +0 -0
  225. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/stun/stun_rfc3489.pcap +0 -0
  226. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/stun.pcap +0 -0
  227. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/tcp_http.pcap +0 -0
  228. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/tcp_https.pcap +0 -0
  229. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/tftp.pcap +0 -0
  230. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/udp_dns.pcap +0 -0
  231. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/vlan_icmp.pcap +0 -0
  232. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/vrrp.pcap +0 -0
  233. {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/vxlan.pcap +0 -0
  234. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fuzz/fuzz_http_decode.py +0 -0
  235. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ah.py +0 -0
  236. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_arp.py +0 -0
  237. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_cdp.py +0 -0
  238. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_checksum.py +0 -0
  239. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_dhcp.py +0 -0
  240. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_diagnostics.py +0 -0
  241. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_enums.py +0 -0
  242. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_esp.py +0 -0
  243. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ethernet.py +0 -0
  244. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_exports.py +0 -0
  245. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_extract_http_streams.py +0 -0
  246. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_filter_codeql_sarif.py +0 -0
  247. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_flow.py +0 -0
  248. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_gre.py +0 -0
  249. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_hsrp.py +0 -0
  250. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_http.py +0 -0
  251. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_icmp.py +0 -0
  252. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_igmp.py +0 -0
  253. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ip.py +0 -0
  254. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ipip.py +0 -0
  255. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ipv6_ext.py +0 -0
  256. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ipv6_routing_final_dst.py +0 -0
  257. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_llc.py +0 -0
  258. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_lldp.py +0 -0
  259. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_mpls.py +0 -0
  260. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_mutmut_gate.py +0 -0
  261. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ntp.py +0 -0
  262. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ospf.py +0 -0
  263. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_packet.py +0 -0
  264. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_pattern_matching.py +0 -0
  265. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_pcap.py +0 -0
  266. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_pim.py +0 -0
  267. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_radius.py +0 -0
  268. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_registry.py +0 -0
  269. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_rip.py +0 -0
  270. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_sctp.py +0 -0
  271. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_stp.py +0 -0
  272. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_tcp.py +0 -0
  273. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_tftp.py +0 -0
  274. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_tlv.py +0 -0
  275. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_udp.py +0 -0
  276. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_utils.py +0 -0
  277. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_version.py +0 -0
  278. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_vlan.py +0 -0
  279. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_vrrp.py +0 -0
  280. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_vxlan.py +0 -0
  281. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_walk.py +0 -0
  282. {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_workflows.py +0 -0
@@ -146,7 +146,8 @@ amass.txt
146
146
  amass.log
147
147
 
148
148
  # Application-specific
149
- .logs/tests/fixtures/staging/
149
+ .logs/
150
+ tests/fixtures/staging/
150
151
 
151
152
  # Written by the benchmark CI step (scripts/benchmark.py output)
152
153
  benchmark.out
@@ -399,15 +399,17 @@ unless the Security Association uses a confidentiality-free transform
399
399
  (e.g. NULL, RFC 2410, or AES-GMAC, RFC 4543), and at an offset only
400
400
  the SA determines, so the decoder never reads it).
401
401
 
402
- `BGP`, `STUN`, `MPLS` and `LLC` are the four exceptions: they answer
403
- from decode context rather than from a wire field alone. A BGP message
404
- carries no payload: a TCP segment can hold several messages back to
405
- back, each decoded as its own layer, so `BGP.next_protocol()` names the
406
- next message *beside* it — `BGP` when the bytes after this message
407
- begin another one, `None` when they do not. STUN over TCP is the same
408
- case, and `STUN.next_protocol()` answers `STUN` the same way, when the
409
- 8 bytes after the message begin like a message's header, its magic
410
- cookie in place. An MPLS label stack carries
402
+ `BGP`, `STUN`, `TLS`, `MPLS` and `LLC` are the five exceptions: they
403
+ answer from decode context rather than from a wire field alone. A BGP
404
+ message carries no payload: a TCP segment can hold several messages
405
+ back to back, each decoded as its own layer, so `BGP.next_protocol()`
406
+ names the next message *beside* it — `BGP` when the bytes after this
407
+ message begin another one, `None` when they do not. STUN over TCP is
408
+ the same case, and `STUN.next_protocol()` answers `STUN` the same way,
409
+ when the 8 bytes after the message begin like a message's header, its
410
+ magic cookie in place; so are TLS records, and `TLS.next_protocol()`
411
+ answers `TLS` when as much of the next 3 bytes as the segment holds
412
+ passes `TLS.decode()`'s guard. An MPLS label stack carries
411
413
  a packet but no field naming its protocol (see *MPLS: dispatch by
412
414
  convention* below), so `MPLS.next_protocol()` infers one, partly from
413
415
  the first byte after the stack. SAP 0x42 carries GARP's PDUs (Protocol
@@ -421,12 +423,13 @@ records what it saw on the instance in a field kept out of the
421
423
  dataclass's comparison and hash (`compare=False`, the mechanism `HTTP`
422
424
  uses for its `_message` parse cache). Two equal headers compare equal
423
425
  however they were found, and can still chain differently. The rules of
424
- the decode contract hold for all four unchanged; see *Port-based
426
+ the decode contract hold for all five unchanged; see *Port-based
425
427
  dispatch is best-effort* below,
426
428
  [`_base.py`](src/netprotocols/_base.py)'s *Decode context in
427
429
  next_protocol*, the module docstrings of
428
430
  [`layer7/bgp.py`](src/netprotocols/layer7/bgp.py),
429
- [`layer7/stun.py`](src/netprotocols/layer7/stun.py) and
431
+ [`layer7/stun.py`](src/netprotocols/layer7/stun.py),
432
+ [`layer7/tls.py`](src/netprotocols/layer7/tls.py) and
430
433
  [`layer2/mpls.py`](src/netprotocols/layer2/mpls.py), and
431
434
  [`layer2/llc.py`](src/netprotocols/layer2/llc.py)'s *DSAP 0x42*.
432
435
 
@@ -502,6 +505,8 @@ flowchart LR
502
505
  U -->|3478| STUN[STUN message]
503
506
  T -->|3478| STUN
504
507
  STUN -->|another message in the segment| STUN
508
+ T -->|443| TLS[TLS record]
509
+ TLS -->|another record in the segment| TLS
505
510
  ```
506
511
 
507
512
  IPv6 extension headers (Hop-by-Hop Options, Routing, Fragment,
@@ -916,9 +921,10 @@ one-prefix UPDATEs, and a capture taken with GRO or TSO on, over
916
921
  loopback or on a jumbo-frame link sees segments of up to 64 KB, so a
917
922
  strict walk raises `MaxDepthExceededError` on valid traffic and a lax
918
923
  one stops with `stopped_by` set and `consumed` short of the frame. A
919
- larger `max_depth` or a second walk,
920
- `decode_frame(frame[packet.consumed:], start=BGP)`, decodes the rest;
921
- the busiest segment of the captured session in the corpus, taken with
924
+ larger `max_depth` decodes the rest, and so does walking on from
925
+ `packet.consumed` with `start=BGP` as many times as it takes (a walk
926
+ that starts there has room for 32 messages, so the 76 KEEPALIVEs take
927
+ three walks); the busiest segment of the captured session in the corpus, taken with
922
928
  offload off, carries 7. Reassembling
923
929
  the stream is the caller's, as it is for `HTTP` below: see
924
930
  [`layer7/bgp.py`](src/netprotocols/layer7/bgp.py)'s module docstring.
@@ -949,6 +955,99 @@ one stop short. BGP's two ways past it work the same, with
949
955
  `start=STUN`; the corpus's TCP segments carry 2. See
950
956
  [`layer7/stun.py`](src/netprotocols/layer7/stun.py)'s module docstring.
951
957
 
958
+ TLS on TCP 443 (RFC 9846 §5.1, which obsoletes RFC 8446 as TLS 1.3's
959
+ specification, and RFC 5246 §6.2, TLS 1.2's, which RFC 9846 also
960
+ obsoletes) has a weaker guard than BGP's or STUN's, but one byte at a
961
+ time, as HSRP's is: a ContentType of 20-24 (change_cipher_spec, alert,
962
+ handshake, application_data and RFC 6520's heartbeat), a version whose
963
+ first byte is 3 and second 0-4 (SSL 3.0 to TLS 1.3 in form), each judged
964
+ as soon as it is in the buffer, then a length of at most 2^14 + 2048
965
+ (RFC 5246 §6.2.3's ciphertext bound, the loosest any version sets),
966
+ judged once both its bytes are, and only then the length against the
967
+ buffer. Random bytes pass the first three 25 times in 2^24, about once
968
+ in 671,000. Measured the same way (`random.Random` seeds 0-2, each
969
+ 10,000 random 300-byte TCP payloads to port 443 and 10,000 of uniformly
970
+ random length 0-600, the latter walked unpadded and padded: 90,000
971
+ walks), every walk ended with `stopped_by` set and none decoded a TLS
972
+ layer: about 0.13 of them are expected to pass the first three checks
973
+ (90,000 x 25 / 2^24) and about 0.0006 to hold a whole record as well.
974
+ With the first three bytes forced to a valid header, `TLS.decode()`
975
+ accepted 449 of 100,000 random 300-byte payloads (`random.Random(0)`),
976
+ near the 452 expected: a length of 0-295 is 296 of the 65,536 values.
977
+ The fragment is not judged, and an SSL 2.0-compatible ClientHello, its
978
+ first byte's top bit set, is turned away at the ContentType. Records
979
+ follow one another in the stream as BGP's messages do, and chain the
980
+ same way, so the rest of BGP's paragraph holds here: a segment that ends
981
+ inside a record raises `TruncatedHeaderError` at it, one that begins
982
+ inside one almost always fails the guard, a bare ACK raises
983
+ `TruncatedHeaderError` at the empty payload
984
+ (`tests/fixtures/tcp_https.pcap`'s five IPv6 ACKs do, and
985
+ `tests/test_corpus.py` walks them lax), and `max_depth` bounds
986
+ correctness. One difference from BGP's port: an IPv4 bare ACK with no
987
+ TCP options is a 54-byte frame, and where the capture holds the padding
988
+ that brings it to Ethernet's 60 bytes, the walk, which does not yet end
989
+ a buffer at IPv4's Total Length (#235), hands TLS those 6 zero bytes,
990
+ which it rejects as a ContentType of 0 (`InvalidFieldError`,
991
+ `field="content_type"`), where BGP, judging nothing before its 19-byte
992
+ header is whole, still raises `TruncatedHeaderError`. As for
993
+ `max_depth`, the decoder's worst case is a bare 5-byte header, 289 to a
994
+ 1448-byte segment; the smallest protected TLS 1.3 record is 5 + 1 + 16
995
+ = 22 bytes (an empty Application Data fragment is protected with its
996
+ inner content type and, under AES-GCM, ChaCha20-Poly1305 or AES-CCM, a
997
+ 16-byte tag; RFC 9846 §5.2), or 5 + 1 + 8 = 14 with
998
+ TLS_AES_128_CCM_8_SHA256's 8-byte tag, so a
999
+ 1448-byte segment holds 65 or 103 records and 30 of 22 bytes, 660 bytes,
1000
+ already exceed the 29 the default leaves; the corpus's busiest segment
1001
+ carries 6. The case that matters is a ClientHello larger than a segment:
1002
+ Chromium's, with its X25519MLKEM768 key share, is a 1738-byte record
1003
+ that TCP carried in two segments in
1004
+ `tests/fixtures/tls/tls_session.pcap`, so a strict walk raises at both
1005
+ and a lax one stops at TCP. There is no lenient mode of `TLS.decode()`
1006
+ that reads a partial hello: the SNI of such a client comes for certain
1007
+ from reassembling its stream and calling `TLS.decode()` on that, as for
1008
+ `HTTP` below. Without reassembly, `peek_server_name()` (#285), a
1009
+ separate, opt-in function and not a decode mode, walks a ClientHello
1010
+ from the start of its record as far as the bytes go, the first record
1011
+ at most, and returns a `TLSServerNamePeek` with one of three outcomes:
1012
+ *found* (`complete`, the name), *absent* (`complete`, `None`: the
1013
+ first record carries no host name, because it is not a ClientHello,
1014
+ or enough of it is present to show it names no host) or *not reached*
1015
+ (not `complete`: the bytes ended first, which is not "no SNI").
1016
+ *Absent* speaks for the first record only: a ChangeCipherSpec and
1017
+ then a ClientHello with a name in one segment, as TLS 1.3's middlebox
1018
+ compatibility mode sends after a HelloRetryRequest (RFC 9846 Appendix
1019
+ E.4), is *absent*, as `TLS.server_name` is `None` for that first
1020
+ record; a caller who wants to look past whole records calls
1021
+ `TLS.decode()` on the first and peeks `data[tls.header_len:]`. Under
1022
+ ECH a *found* name is the outer ClientHello's, the client-facing
1023
+ server's `public_name`, as for `TLS.server_name`. A prefix that ends
1024
+ before the answer is known is *not reached*; where a prefix ends never
1025
+ by itself causes a raise. Bytes present that are malformed raise what
1026
+ the strict path raises for them, the guard's errors and the
1027
+ ClientHello's `InvalidFieldError`s with the same `field` and `offset`;
1028
+ nothing is re-encoded and no `TLS` is built. A ClientHello fragmented
1029
+ across records is read to its first record's end, where the strict
1030
+ accessors raise `TruncatedHeaderError`. How often it finds the name:
1031
+ curl's, gnutls-cli's and `openssl s_client`'s ClientHellos fit one
1032
+ segment, so always; Chromium's is 1738 bytes and shuffles its
1033
+ extensions, and the name ends inside the first 1448 bytes for 54.42% of
1034
+ its orders (exact; 54.45% and 54.40% over 100,000 `random.Random(0)`
1035
+ permutations of each captured hello's 17 non-GREASE extensions, GREASE
1036
+ kept first and last), as it did for the first of its two captured
1037
+ connections, at byte 184, and not the second, at byte 1729, behind its
1038
+ 1267-byte key share. The function's docstring gives the method.
1039
+ `TLS.client_hello` and `TLS.server_name` parse the fragment on demand
1040
+ and never in `decode()`; there is no cryptography, and nothing past the
1041
+ ClientHello's fields and its server_name extension is decoded (the
1042
+ ServerHello, certificates, key exchange and every other extension stay
1043
+ raw, handshake messages fragmented across records or coalesced into one
1044
+ are not reassembled or split, and DTLS and QUIC, on UDP 443, are not
1045
+ decoded). Under Encrypted Client Hello the name read is the outer
1046
+ ClientHello's, the client-facing server's. Only 443 is registered: DNS
1047
+ over TLS's 853, the mail ports 465, 993 and 995, and 8443 need
1048
+ `decode_as`. See [`layer7/tls.py`](src/netprotocols/layer7/tls.py)'s
1049
+ module docstring.
1050
+
952
1051
  A rejected guess takes the ordinary decode-error path: the chain keeps
953
1052
  the layers it did decode and records the failure. An accepted one
954
1053
  yields a layer that decoded cleanly and is wrong, and nothing in the
@@ -968,7 +1067,8 @@ that happen to fit in one segment. Of the 98 frames in
968
1067
  raise `TruncatedHeaderError`, and the 12 that begin mid-message raise
969
1068
  `InvalidFieldError`. So it needs `lax=True`, and it is still not
970
1069
  reassembly. Reassemble the connection and call `HTTP.decode()` on its
971
- bytes instead.
1070
+ bytes instead. HTTPS is not HTTP on the wire: port 443 dispatches to
1071
+ `TLS`, and the HTTP inside it is encrypted.
972
1072
 
973
1073
  ## The encode side, and the round-trip guarantee
974
1074
 
@@ -1082,9 +1182,10 @@ src/netprotocols/
1082
1182
  ├── __init__.py public API re-exports, __version__, registry install
1083
1183
  ├── _base.py Protocol ABC, decode contract, address helpers
1084
1184
  ├── _tlv.py walk_kind_length_value(): shared TCP/IPv4/IPv6
1085
- │ options, PIM Hello options and STUN attributes
1086
- │ TLV walker
1087
- ├── _enums.py EtherType, IPProtocol, ARPOperation (imports nothing)
1185
+ │ options, PIM Hello options, RADIUS attributes and
1186
+ │ STUN attributes TLV walker
1187
+ ├── _enums.py EtherType, LLCSAP, IPProtocol, ARPOperation,
1188
+ │ ARPHardwareType (imports nothing)
1088
1189
  ├── registry.py public dispatch tables: register(), Registry
1089
1190
  ├── walk.py decode_frame(): the shipped chain walker
1090
1191
  ├── _defaults.py the built-in decoder map, installed at import
@@ -1119,7 +1220,11 @@ src/netprotocols/
1119
1220
  │ radius.py (RADIUS + RADIUSAttribute),
1120
1221
  │ stun.py (STUN messages, over UDP and TCP),
1121
1222
  │ ntp.py (NTPv1-v4 time messages),
1122
- │ hsrp.py (HSRPv1 Hello, Coup and Resign)
1223
+ │ hsrp.py (HSRPv1 Hello, Coup and Resign),
1224
+ │ tls.py (TLS records, the ClientHello and its
1225
+ │ SNI: TLS + TLSClientHello + TLSExtension, and
1226
+ │ peek_server_name + TLSServerNamePeek for a
1227
+ │ ClientHello's prefix)
1123
1228
  └── utils/ validators (mac.py, ipv4.py), exceptions.py
1124
1229
  tests/ one file per protocol + test_contract.py
1125
1230
  (truncation, lying lengths, chain walks),
@@ -7,13 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.8.0] - 2026-11-04
11
+
12
+ ### Added
13
+
14
+ - **`TLS`: TLS record framing (RFC 9846 §5.1, which obsoletes RFC 8446, and RFC 5246 §6.2, TLS 1.2's, which RFC 9846 also obsoletes) and the ClientHello's SNI (RFC 6066) (#196).** TCP port 443 dispatches, best-effort like every port entry, to the new `netprotocols.TLS` (also `netprotocols.layer7.TLS`), one layer per record: the 5-byte header decoded into `content_type`, `legacy_version` and `length`, and the `fragment` kept raw, so `bytes(TLS.decode(x)) == x[:header_len]`. **No cryptography and no handshake beyond the ClientHello's fields: nothing is decrypted, verified or derived, and the ServerHello, the certificates, the key exchange and every extension but server_name stay raw.** Against a wrong port guess, `decode()` judges each of the header's first three bytes as soon as it is in the buffer, and the length once both its bytes are: a ContentType outside 20-24 (`InvalidFieldError`, `field="content_type"`, which also turns away SSL 2.0-compatible hellos), a version whose first byte is not 3 or second above 4 (`"legacy_version"`), then a length above 2^14 + 2048, RFC 5246 §6.2.3's bound (`"length"`), each with `expected` and `actual`, and only then raises `TruncatedHeaderError` for a record the buffer cuts short or a short header whose first three bytes pass; random bytes pass the header 25 times in 2^24, and over 90,000 random payloads to port 443 every walk stopped and none decoded a TLS layer. As for BGP's messages, records in one segment chain: `decode()` notes, outside the comparison and hash, whether as much of the next 3 bytes as the segment holds passes the guard, and `TLS.next_protocol()` then answers `TLS`; a cut record raises `TruncatedHeaderError`, a bare ACK raises `TruncatedHeaderError` at the empty payload (but an IPv4 one with no TCP options that carries Ethernet's padding hands TLS 6 zero bytes, since the walk does not yet honour IPv4's Total Length, #235, and raises `InvalidFieldError` at `content_type`), and a segment of more than 29 records exceeds the default `max_depth`, which valid traffic reaches: the smallest protected TLS 1.3 record is 22 bytes (5 of header, 1 of inner content type and a 16-byte AEAD tag around empty Application Data, RFC 9846 §5.2), or 14 with TLS_AES_128_CCM_8_SHA256's 8-byte tag, so 30 of 22 bytes take 660 bytes of a segment. Accessors parse the fragment on demand, never in `decode()`: `handshake_type` (the first byte of a non-empty Handshake record's fragment, ciphertext for TLS 1.2's encrypted Finished, which the record layer cannot tell apart), `client_hello`, a `TLSClientHello` (`legacy_version`, `random`, `legacy_session_id`, `cipher_suites` and `legacy_compression_methods` raw, and `extensions`, a tuple of `TLSExtension(type, data)`, `None` for a ClientHello with no extensions block and `()` for an empty one), and `server_name`, the first host_name of the server_name extension decoded as ASCII, `None` without one; a record holding less than the ClientHello's length raises `TruncatedHeaderError`, and a length inside it that overruns, a session ID over 32 bytes, bytes after the extensions, or a malformed, empty or non-ASCII server name raise `InvalidFieldError`, both with `field="fragment"` and an `offset` within it, inside the server_name extension too. The ClientHello is recognized by the fragment's first byte alone, so a TLS 1.2 encrypted Finished whose ciphertext starts with 1 makes both accessors raise rather than answer `None`. Display helpers name the content type, version ("TLS 1.2"), handshake type and extension type as RFC 9846 and the RFCs that assign them do (every extension type RFC 9846 §4.3 lists, and seven more a ClientHello commonly carries), GREASE (RFC 8701) included, and say "unknown (n)" otherwise. Only the first handshake message of a record is looked at: a handshake message fragmented across records, or several coalesced in one, is not reassembled or split. A ClientHello larger than one segment, such as Chromium's with its X25519MLKEM768 key share, makes a strict walk raise `TruncatedHeaderError` at its first segment and `InvalidFieldError` at the next, which begins inside the record; its SNI comes from reassembling the client's stream and decoding that, or, opt-in, from peeking its first segment with `peek_server_name()` (below), and `TLS.decode()` has no lenient partial-hello mode. Under Encrypted Client Hello the SNI read is the outer ClientHello's. Only 443 is registered: 853, 465, 993, 995 and 8443 need `decode_as`. DTLS and QUIC (UDP 443) are not decoded.
15
+ - **`peek_server_name()`: the SNI host name from a ClientHello's first segment, opt-in (#285).** A ClientHello larger than one TCP segment, such as Chromium's 1738-byte one with its X25519MLKEM768 key share, gives no SNI to a per-packet walk: `TLS.decode()` raises `TruncatedHeaderError` at its first segment. The new `netprotocols.peek_server_name(data)` (also `netprotocols.layer7.peek_server_name`) reads from a prefix instead: `data` starts at a record header, as a segment's payload does, and may end anywhere, and the ClientHello is walked as far as the bytes go, the first record at most, reusing the strict parsers. It returns a `TLSServerNamePeek`, a frozen value with `complete` and `server_name` (a name only with `complete`, and never empty), and one of three outcomes: *found* (`complete`, the name, once the whole server_name extension is present), *absent* (`complete`, `None`: the first record carries no host_name, because it is not a ClientHello, or the bytes show it names no host: no extensions block, the block walked to its end without server_name, or a list with no host_name) and *not reached* (not `complete`, `None`: the bytes, or the first record, ended first). *Absent* speaks for the first record only: a ChangeCipherSpec and then a ClientHello with a name in one segment, as TLS 1.3's middlebox compatibility mode sends after a HelloRetryRequest (RFC 9846 Appendix E.4), is *absent*, as `TLS.server_name` is `None` for that first record; to look past whole records, `TLS.decode()` the first and peek `data[tls.header_len:]`. *Not reached* is not "no SNI": reassemble the stream for certain. Under Encrypted Client Hello a *found* name is the outer ClientHello's, the client-facing server's `public_name`, as for `TLS.server_name`. A prefix that ends before the answer is known, inside the record header, the Random, a length, an extension header or the name, is *not reached*, and where a prefix ends never by itself causes a raise; bytes present that are malformed raise the strict path's `InvalidFieldError`, the record guard's (`content_type`, `legacy_version`, `length`) or the ClientHello's (`field="fragment"`), with the same `offset`, `expected`, `actual` and message. A ClientHello fragmented across records is read to the first record's end, never joined to the next. A complete answer holds for every longer prefix and equals `TLS.decode(record).server_name` wherever that answers; nothing is re-encoded, no `TLS` is built, and the work is bounded by `len(data)`. Measured: curl's, gnutls-cli's and `openssl s_client`'s ClientHellos fit one segment, so the answer is always complete; Chromium shuffles its extensions, and its server_name ends inside the first 1448 bytes for 54.42% of orders (54.45% and 54.40% over 100,000 permutations of each captured hello), found in the first captured connection's first segment and not reached in the second's. **`TLS.decode()`'s strict contract is unchanged, and so is the boundary: no cryptography and no handshake beyond the ClientHello's fields.**
16
+
17
+ ### Fixed
18
+
19
+ - **The encode benchmark compared unlike workloads, and so did the "faster than dpkt at re-encoding" claim built on it (CodeRabbit on #296).** `scripts/benchmark_encode.py` timed `bytes(Ethernet.decode(FRAME))` for netprotocols, which re-encodes the 14-byte Ethernet header only, against `bytes(dpkt.ethernet.Ethernet(FRAME))`, which serializes the whole Ethernet/IPv4/UDP frame (and fills a zero IPv4 checksum as it does). It now re-encodes the same whole frame in every library, `bytes(decode_frame(FRAME))` for netprotocols, from a frame all three decode fully in strict mode on ports none dispatches on, with valid checksums precomputed, and asserts before timing that each output equals the frame byte for byte. The README's "4.2× faster than dpkt at re-encoding" is now 1.3× on a whole frame (1.34×, median of three pinned runs); every earlier figure in `docs/CLAIMS.md` 1.3, 5.3× at v1.3.0 through 4.15× for 2.8.0's first measurement, timed a header against a frame, and is labelled as not like for like there (on one machine: 4.14× as benchmarked, 1.39× on the whole frame, 1.17× on the Ethernet header alone).
20
+
21
+ ### Development
22
+
23
+ - **Every comparative number the README quotes was re-measured for this release** (Rule 4 of `docs/CLAIMS.md`), on the 394-frame corpus and the machine and method 2.7.0 used, against `dpkt 1.9.8` and `scapy 2.8.0`: dpkt decodes the corpus 1.51× faster (from 1.37×; it stops at TCP on the new TLS frames, 2.04× there) and netprotocols is 3.3× ahead of scapy; encode is 1.34× ahead of dpkt on a whole Ethernet/IPv4/UDP frame, after the benchmark was corrected (see Fixed; the 4.15× this entry first quoted compared a header with a frame); a warm import takes 55.2 ms against `scapy.all`'s 235.3 ms (4.3×); the wheel is 285.1 KiB, 9.5× smaller than scapy's; and netprotocols decodes deeper than dpkt on 175 of 394 frames. TLS's dispatch on port 443 makes the strict walk reject 5 `tcp_https.pcap` segments that do not begin a record. Also re-checked: CLAIMS 1.7's ten-project CI audit (still none gates; pcapkit now runs a weekly benchmark that compares nothing), 3.1's import simulation with `dpkt 1.9.8`, `pypacker 5.4` and `scapy 2.8.0` pinned, and 4.1's dpkt dates; §7.1 counts all 21 protocols on #170's list as released. README's roadmap names Tier 5 as 2.8.0.
24
+ - **#170's competitor protocol survey, re-run against the current PyPI releases and recorded as a claim (part of #197).** New `docs/CLAIMS.md` §7.1 repeats the module-list survey #170 built its five tiers from, one row per item on its list, against `dpkt 1.9.8`, `scapy 2.8.0` and `pypacker 5.4` (each the latest release on 2026-11-01, named with its upload date and sha256): all 21 decode on `master`, 15 of them in 2.6.0 and the six of Tiers 4 and 5 (NTP, TFTP, RADIUS, STUN, HSRP, TLS) merged but unreleased, so the claim is `VERIFIED` for `master` and `GATED ON` #174/#175 for a release. scapy 2.8.0 decodes all 21 (eight only after `load_contrib()` or `load_layer()`), dpkt 1.9.8 19, one in part (no VXLAN or LLDP; TLS records with no server_name decoding) and pypacker 5.4 17, three in part (no IP-in-IP, since it dispatches protocol 41 but not 4, and no VXLAN, MPLS or CDP; an IPv6 AH header its extension-header walk sizes in 8-octet units rather than RFC 4302's (Payload Len + 2) × 4, which agree only at a Payload Len below AH's 12-octet fixed part, mis-framing every valid AH's ICV and everything after it, an `LLC` class its Ethernet never reaches and whose non-SNAP path drops the SNAP field yet returns a header length of 8, not 3, reading every non-SNAP PDU, STP's included, 5 bytes late, and TLS with no server_name decoding), a partial cell counting toward the total and meaning one that lacks the feature the row's title names (TLS's server_name) or mis-frames the protocol's own header (pypacker's AH and non-SNAP LLC); how far a decoder goes beyond that, which message bodies, versions, formats or optional fields it covers, is not scored, since #170 counted modules, and dispatch is scored only in the IP-in-IP (#177, IP protocol 4) and LLC / SNAP (#188, the 802.3 Length) rows, whose issues are that dispatch. A "yes" is therefore a claim about a module, not its depth, and §7.1 names the depth gaps known and left unscored: dpkt's and pypacker's OSPF and PIM decode the common header only (no Hello, LSA header or Join/Prune), dpkt's VRRP is v2 over IPv4 only and pypacker's the 8-octet v2 header with its addresses undecoded (scapy has VRRPv3 and IPv6), their STUN has RFC 3489's generic attributes and no XOR-MAPPED-ADDRESS (scapy's contrib has `STUNXorMappedAddress`), all three decode a 1-octet LLC Control only where #188 asks for 1 or 2, and all three STPs one fixed 35-octet BPDU, with no Topology Change Notification short form or RSTP Version 1 Length; of the competitors only scapy has released since #170 opened, and none of 2.8.0's new modules is on the list. The new `scripts/survey_protocols.py` reproduces the table: stdlib-only and static, it runs regular expressions over the unpacked releases' source files as text, imports and executes none of them, and prints the file and class behind every "yes" and "partial" and, behind every "no", a class or handler pattern that matches none of the package's files; a check whose file is missing fails rather than passes. It also prints the eleven protocols all three decode and netprotocols does not export (PPP, PPPoE, IEEE 802.11, Radiotap, Linux cooked capture, Cisco DTP, ATA over Ethernet, RTP, Diameter, SMB, Portmap), a list swept from dpkt's and pypacker's module lists and checked against scapy's rather than recalled, recorded as a survey and not a roadmap. A status audit of every claim for #101 relabels 1.1-1.6 `VERIFIED for 2.6.0` and `NEEDS RE-MEASUREMENT` for the next release, since their figures describe the 2.6.0 tree the README quotes them for, and labels `NEEDS RE-MEASUREMENT`, without re-measuring, the two it found stale: 1.7, whose ten-project CI audit dates from 2026-09-04, and 3.1, whose simulation table names no dpkt or pypacker version; the README quotes both as published for 2.6.0 and is left as it is, and each is to be re-run before the next release's README quotes it. 4.1's count of dpkt's open issues, a lookup rather than a measurement, goes from 77 to 81 with the search that returns it; no other figure changed, and the README, which does not quote that count, is untouched. `ARCHITECTURE.md`'s Layout map now names RADIUS among `_tlv.py`'s callers and lists all five of `_enums.py`'s enums (`LLCSAP` and `ARPHardwareType` were missing); every module under `src/netprotocols/` was already in it, and no doc or source comment still calls a shipped protocol unimplemented.
25
+ - **`tests/fixtures/staging/` is ignored and untracked, as the capture workspace it was meant to be.** `.gitignore`'s "Application-specific" section held `.logs/tests/fixtures/staging/`, two entries merged by a missing newline, which matched nothing, so every capture PR committed a duplicate of its pcaps under `staging/` beside the copy folded into the corpus. The line is split into `.logs/` and `tests/fixtures/staging/`, and the 41 duplicate staging copies are untracked with `git rm --cached`, which leaves them on disk only in the checkout that ran it: a clone that pulls this change deletes them. Nothing is lost, since each is byte-identical to a file still tracked, 40 at the same path under `tests/fixtures/` and `staging/http.pcap` at `tests/fixtures/http/http.pcap`, and every capture script recreates `staging/` when it runs. The sdist, which hatchling builds honouring `.gitignore`, no longer ships the 41 copies (its `tests/fixtures/` entries go from 98 to 57). No test or CI job read them: the corpus, `tests/test_docs.py`'s scenario count and the fuzz seeds glob `tests/fixtures/*.pcap` non-recursively, and `scripts/check_fixtures.py tests/fixtures` still reports 394 frames across 36 scenarios. Going forward, as `CONTRIBUTING.md` now says: capture into `staging/`, validate with `scripts/check_fixtures.py`, then copy the validated pcap and any other files the capture script names into `tests/fixtures/` or the subdirectory it names, and commit only those.
26
+ - **A real TLS capture joins the corpus: `tls.pcap`, 15 frames (394 across 36 scenarios), and the session it was cut from outside it.** `scripts/capture_fixtures_tls.sh` runs nginx 1.24 (OpenSSL 3.0) with a self-signed ECDSA P-256 certificate generated in a scratch directory (no key is committed) in one netns, and five clients in another, over a veth pair with offload off, each reaching it by host name through a per-netns `/etc/hosts`: headless Chromium 153 (BoringSSL), curl (OpenSSL 3.0), gnutls-cli 3.8, `openssl s_client -noservername` and curl capped at TLS 1.2. The corpus keeps 15 segments of whole records: each client's ClientHello but Chromium's, which spans two segments, the server's TLS 1.3 flights (six records a segment), Chromium's ChangeCipherSpec and Finished, and every data segment of the TLS 1.2 connection, ChangeCipherSpec, the encrypted Finished and the close_notify Alert among them; `tests/fixtures/tls/tls_session.pcap` keeps all 108 frames of the six connections. The script reassembles each connection with `scripts/extract_http_streams.py`'s `extract()` and asserts the connections, whole records on both sides, each ClientHello's SNI, Chromium's GREASE, X25519MLKEM768 key share, two segments and two extension orders, and every corpus frame's content types. `scripts/check_fixtures.py` now checks TLS records on port 443, since TLS has no checksum beyond TCP's (`tls-length`, `tls-empty`, `tls-ccs`, `tls-hello` and, for the server_name, `tls-sni`), counts them (`tls`, one counter per content type, `tls-client-hello`, `tls-server-name`) and counts a segment that begins or ends inside a record as `tls-unchecked`, which fails a file (`tls-split`) unless its name ends `_session.pcap`, so every corpus segment on port 443 that carries data must be whole records while the session capture keeps Chromium's split ClientHellos; `tests/test_tls.py` fails each check on a tampered copy of a captured frame. `tcp_https.pcap`'s seven IPv4 segments now each decode one application_data record, and its five IPv6 bare ACKs from port 443 raise `TruncatedHeaderError` in a strict walk, as a bare ACK on BGP's port does; `tests/test_corpus.py` names them and walks them lax. A ClientHello without its server_name and one whose extensions block is cut are derived from captured bytes in `tests/test_tls.py`. `TLS` joins the fuzz suite (`ALL_PROTOCOLS`, a round-trip strategy, seeds from every captured ClientHello, Chromium's two reassembled, and the constructed one, and properties that decode raises only for the guard's first failing reason, that records chain exactly when the next bytes begin like one, that the ClientHello accessors raise only on the fragment, and that built ClientHellos parse back), the truncation contract test, the corpus coverage set and the layout-map check.
27
+
10
28
  ## [2.7.0] - 2026-11-04
11
29
 
12
30
  ### Added
13
31
 
14
32
  - **`TFTP`: the Trivial File Transfer Protocol (RFC 1350), with option negotiation (RFC 2347-2349) (#192).** UDP port 69 dispatches, best-effort like every port entry, to the new `netprotocols.TFTP` (also `netprotocols.layer7.TFTP`), which decodes the 2-byte `opcode` and keeps the message after it as raw `body`, read on demand by opcode: `filename`, `mode` and `options` for an RRQ or WRQ, `block` and `data` for DATA, `block` for ACK, `error_code`, `error_name` and `error_message` for ERROR, and `options` for an OACK (opcode 6, RFC 2347), each `None` (`()` for `options`) where the opcode does not carry it; `opcode_name` names the opcode. Strings are zero-terminated rather than length-prefixed, so they are split with `bytes.find`, not the shared TLV walker, and read as ASCII with any other byte as a backslash escape. Options are `(name, value)` string pairs in wire order; `option(name)` matches a name in any case (RFC 2347), and `blksize` (RFC 2348), `timeout`, `tsize` (RFC 2349) and `windowsize` (RFC 7440) read their value as a number, or `None` if it is absent, not decimal or over 20 digits (leading zeros aside; 20 hold any 64-bit size, and `int()` would raise `ValueError` past 4,300). TFTP has no length field and the walk does not trim to UDP's (#235), so each opcode ends where its structure does and leaves the rest undecoded, `bytes(TFTP.decode(x)) == x[:header_len]`: an ACK after 4 bytes (an untagged Ethernet frame over IPv4 with no options pads it with 14 zeros), an ERROR at its message's zero byte, a request or an OACK after its last whole option pair (a zero byte where a name would start ends the options, so padding after whole pairs is left undecoded, and an option cut short is left undecoded with it). Without the UDP length (#235) a trailer holding a zero byte can still be read as option bytes: after a name with no value, the trailer's first zero byte ends that value (padding decodes `(name, "")` and gives up one byte), and after whole pairs an FCS holding a name, a zero byte, a value and a zero byte (`12 00 34 00`) reads as one more pair. DATA cannot, and its `data` runs to the end of the buffer, a trailer included. `decode()` raises `InvalidFieldError` for an opcode other than 1-6 (65,530 of 65,536 two-byte prefixes, so a wrong port guess is almost always rejected) and `TruncatedHeaderError` for a mandatory field cut short: a block number or error code under 2 bytes, or a filename, mode or error message with no terminating zero byte. **Dispatch reaches the request only.** A client sends its RRQ or WRQ to port 69, but the server answers from an ephemeral port of its own, and the rest of the transfer runs between two ephemeral ports (RFC 1350 §4), which no port table can name: a stateless walk stops at UDP for every DATA, ACK, OACK and ERROR. A caller following the transfer takes the server's port from its first reply and walks with `decode_as={"udp.port": {port: TFTP}}`, which covers both directions, or calls `TFTP.decode()` on the UDP payload.
15
33
  - **`RADIUS`: Remote Authentication Dial In User Service (RFC 2865, RFC 2866, #193).** UDP ports 1812 (authentication) and 1813 (accounting) dispatch, best-effort like every port entry, to the new `netprotocols.RADIUS` (also `netprotocols.layer7.RADIUS`), which decodes the 20-byte header into `code`, `identifier`, `length` and the 16-byte `authenticator`, kept raw, and keeps the attributes the Length covers as raw `attributes`, so `bytes(RADIUS.decode(x)) == x[:header_len]`. The Length decides the packet's extent: below 20 raises `InvalidFieldError` (`field="length"`, `offset=2`), past the buffer `TruncatedHeaderError` (RFC 2865 §3 discards a packet shorter than its Length), and bytes after it, padding by §3 or an FCS, are left undecoded, though since the walk does not yet bound a layer by UDP's length (#235), a Length that overstates the datagram takes trailing bytes such as an FCS as attributes instead of raising; RFC 2865's 4096-byte ceiling is not enforced, since RFC 7930 lifts it for TCP and TLS. A random datagram to either port is rejected about 99.6% of the time (281 of 65,536 Length values accepted for a 300-byte payload), by the Length alone; the Code is not checked, and `code_name` names the codes of RFC 2865, RFC 2866, RFC 5997 (Status-Server) and RFC 5176 (Dynamic Authorization), not every code IANA assigns. `parsed_attributes` walks the attributes on demand with the shared `_tlv.walk_kind_length_value` (a Type byte and a Length byte that counts the two, no lone-byte types, no terminator) into `RADIUSAttribute(type, data)`, raising `InvalidFieldError` for a Length below 2 or one that runs past the packet, never from `decode()`. `RADIUSAttribute.type_name` names the attributes of RFC 2865 §5, RFC 2866 §5 and RFC 3579 §3, and RFC 2869's Event-Timestamp (55), Connect-Info (77), Acct-Interim-Interval (85) and NAS-Port-Id (87), and gives `"unknown (n)"` for any other; `value` decodes User-Name (1) as UTF-8 text, NAS-IP-Address (4) and Framed-IP-Address (8) as `IPv4Address` and NAS-Port (5) as an integer, gives User-Password (2) and Vendor-Specific (26) as their raw bytes, and is `None` for every other type; `RADIUS.user_name`, `nas_ip_address`, `nas_port` and `framed_ip_address` read the first of each. Nothing that needs the shared secret is computed or verified: the Request Authenticator of an Accounting-Request, Disconnect-Request or CoA-Request and the Response Authenticator of a reply, the Message-Authenticator (RFC 3579 §3.2), and the hiding of User-Password; vendor attributes inside Vendor-Specific are not decoded. The legacy ports 1645/1646 and RFC 5176's 3799 are not registered (`decode_as` reads them).
16
- - **`STUN`: Session Traversal Utilities for NAT (RFC 8489, and RFC 5389, which it obsoletes; #194).** UDP and TCP port 3478 now dispatch, best-effort like every port entry, to `netprotocols.STUN` (also `netprotocols.layer7.STUN`), which decodes the 20-byte header into `message_type`, `magic_cookie` and `transaction_id` and keeps the attributes raw, padding included, as `attributes`, so `bytes(STUN.decode(x)) == x[:header_len]`; `length` is `len(attributes)`, not a field. The 14-bit Message Type stays one field, and accessors split the class and method interleaved in it (`message_class`, `method`, `class_name`, `method_name`, `message_type_name`, e.g. "Binding Success Response"). `decode()` verifies the magic cookie (`0x2112A442`, `STUN.MAGIC_COOKIE`) and rejects a message without it with `InvalidFieldError` (`field="magic_cookie"`), so an RFC 3489 message, whose 16-byte transaction ID takes the cookie's place, is not decoded; it also rejects nonzero top two bits (`"message_type"`, which also turns TURN ChannelData away) and a Message Length that is not a multiple of 4 (`"length"`), and raises `TruncatedHeaderError` for a Length past the buffer. Over random payloads to port 3478 every walk stopped. `parsed_attributes` walks the attributes on demand as `STUNAttribute(type, value)` through the shared TLV walker, which gains a private `align` parameter for STUN's 4-byte padding, and raises `InvalidFieldError` on a malformed list; `STUNAttribute` names IANA's attribute types (`type_name`), says which are comprehension-required, reads an address in the clear (`address`), and re-encodes zero-padded as RFC 8489 §14 sends it, while padding of any other value, which RFC 5389 §15 allowed, survives a decode-encode round trip. The other accessors never raise, reading the attributes up to the first malformed one: `attribute(kind)` (the first of duplicates), `mapped_address` and `xor_mapped_address` (`STUNAddress(family, port, address)`, the latter un-XORed with the cookie, and over IPv6 the cookie and transaction ID, leaving the wire bytes untouched), `xor_address()` for TURN's other XOR-encoded addresses, `software`, `error_code` (300-699, `None` for a Class outside 3-6 or a Number above 99, which RFC 8489 §14.8 rules out) and `error_reason`, `unknown_attributes`, `message_integrity` (the raw HMAC-SHA1, unverified: it needs the credential's key) and `fingerprint`. Over TCP, where messages follow one another with no framing but their Length (RFC 8489 §6.2.2), each message decodes as its own layer and `STUN.next_protocol()` answers `STUN` while the next 8 bytes begin like a message, as BGP does; as for BGP, a segment of more than 29 messages exceeds the default `max_depth`, and a 1448-byte one can hold 72 bare 20-byte headers (a larger `max_depth` or a second walk with `start=STUN` decodes the rest). TLS/DTLS (port 5349), RFC 6544 framing and ChannelData are not decoded.
34
+ - **`STUN`: Session Traversal Utilities for NAT (RFC 8489, and RFC 5389, which it obsoletes; #194).** UDP and TCP port 3478 now dispatch, best-effort like every port entry, to `netprotocols.STUN` (also `netprotocols.layer7.STUN`), which decodes the 20-byte header into `message_type`, `magic_cookie` and `transaction_id` and keeps the attributes raw, padding included, as `attributes`, so `bytes(STUN.decode(x)) == x[:header_len]`; `length` is `len(attributes)`, not a field. The 14-bit Message Type stays one field, and accessors split the class and method interleaved in it (`message_class`, `method`, `class_name`, `method_name`, `message_type_name`, e.g. "Binding Success Response"). `decode()` verifies the magic cookie (`0x2112A442`, `STUN.MAGIC_COOKIE`) and rejects a message without it with `InvalidFieldError` (`field="magic_cookie"`), so an RFC 3489 message, whose 16-byte transaction ID takes the cookie's place, is not decoded; it also rejects nonzero top two bits (`"message_type"`, which also turns TURN ChannelData away) and a Message Length that is not a multiple of 4 (`"length"`), and raises `TruncatedHeaderError` for a Length past the buffer. Over random payloads to port 3478 every walk stopped. `parsed_attributes` walks the attributes on demand as `STUNAttribute(type, value)` through the shared TLV walker, which gains a private `align` parameter for STUN's 4-byte padding, and raises `InvalidFieldError` on a malformed list; `STUNAttribute` names IANA's attribute types (`type_name`), says which are comprehension-required, reads an address in the clear (`address`), and re-encodes zero-padded as RFC 8489 §14 sends it, while padding of any other value, which RFC 5389 §15 allowed, survives a decode-encode round trip. The other accessors never raise, reading the attributes up to the first malformed one: `attribute(kind)` (the first of duplicates), `mapped_address` and `xor_mapped_address` (`STUNAddress(family, port, address)`, the latter un-XORed with the cookie, and over IPv6 the cookie and transaction ID, leaving the wire bytes untouched), `xor_address()` for TURN's other XOR-encoded addresses, `software`, `error_code` (300-699, `None` for a Class outside 3-6 or a Number above 99, which RFC 8489 §14.8 rules out) and `error_reason`, `unknown_attributes`, `message_integrity` (the raw HMAC-SHA1, unverified: it needs the credential's key) and `fingerprint`. Over TCP, where messages follow one another with no framing but their Length (RFC 8489 §6.2.2), each message decodes as its own layer and `STUN.next_protocol()` answers `STUN` while the next 8 bytes begin like a message, as BGP does; as for BGP, a segment of more than 29 messages exceeds the default `max_depth`, and a 1448-byte one can hold 72 bare 20-byte headers (a larger `max_depth` decodes the rest, and so does walking on from `packet.consumed` with `start=STUN` as many times as it takes: the 72 take three walks). TLS/DTLS (port 5349), RFC 6544 framing and ChannelData are not decoded.
17
35
  - **STUN's FINGERPRINT in `compute`/`verify`/`Packet.with_checksums()`.** `compute(stun)` is the IEEE CRC-32 (`binascii.crc32`) of the message up to its FINGERPRINT attribute, the header's Length already counting it, XORed with `0x5354554E` (RFC 8489 §14.7, RFC 5389 §15.5; `ip` and `payload` ignored), and raises `InvalidFieldError` unless the last attribute is a 4-byte FINGERPRINT. `verify()` accepts a message with no FINGERPRINT, as it does a GRE header without the C bit, and fails one whose FINGERPRINT is not last. No one field says whether a message has one, so it has none only when its attributes walk whole, none of them a FINGERPRINT, and its last 8 bytes do not begin like one: a corrupted Length earlier in the list, which would end the walk short of the FINGERPRINT or fold it into a longer value, fails rather than reading as a message without one. `with_checksums()` rewrites the attribute's value and leaves MESSAGE-INTEGRITY alone; it now carries a rebuilt layer's decode context across `dataclasses.replace()`, which resets it, so the first of two STUN messages in a TCP segment still names the second in `next_protocol()`. `compute()` and `verify()` were restructured to stay at radon B (8) and A (5): the checksums that cover only the layer's own bytes (IPv4, IGMP, CDP, OSPF, STUN) share one helper, and the "carries no checksum" rules (UDP over IPv4 at 0, GRE without the C bit, OSPF under cryptographic authentication, STUN without FINGERPRINT) another.
18
36
  - **`NTP`: the Network Time Protocol, NTPv4 (RFC 5905) and NTPv3 (RFC 1305) (#191).** UDP port 123 dispatches, best-effort like every port entry, to the new `netprotocols.NTP` (also `netprotocols.layer7.NTP`), which decodes the 48-byte header (RFC 5905 §7.3) into `leap`, `version`, `mode`, `stratum`, `poll`, `precision`, `root_delay`, `root_dispersion`, `reference_id` and the four timestamps, `reference_timestamp`, `origin_timestamp`, `receive_timestamp` and `transmit_timestamp`, each the raw wire value (Poll and Precision signed log2 seconds, Root Delay and Dispersion the 32 bits of a 16.16 fixed-point number, timestamps 32.32 seconds since 1900), and keeps everything after it as raw `body`, so `bytes(NTP.decode(x)) == x`. Display properties convert alongside without replacing the raw fields: `reference_time`, `origin_time`, `receive_time` and `transmit_time` give an aware UTC `datetime` (the module's `ntp_to_datetime()` and `ntp_to_unix()` convert any timestamp), reading a timestamp whose top bit is clear as era 1, from 2036-02-07T06:28:16Z, as RFC 4330 §3 does, and 0, which RFC 5905 §6 reserves for an unknown time, as `None`; `root_delay_seconds`, signed below version 4 as RFC 1305 and RFC 2030 define it and unsigned for NTPv4 as RFC 5905's short format is (so `0xffff0000` is -1.0 s in NTPv3 and 65535.0 s in NTPv4), `root_dispersion_seconds`, unsigned (both assume NTPv2-4's meanings and do not apply to NTPv1, whose second and third words are other fields), `poll_seconds` and `precision_seconds`; `leap_name`, `mode_name` and `stratum_name` (RFC 5905 Figures 9-11); `kiss_code` for a stratum 0 Kiss-o'-Death (§7.4); and `reference_id_str`, ASCII at stratum 0 and 1 and a dotted IPv4 address from stratum 2. NTP has no length field and the walk does not trim to UDP's (#235), so the accessors split the body without raising: `extension_fields`, NTPv4's RFC 7822 fields as `NTPExtensionField(type, value)` (`type_name` gives the names IANA's registry has since RFC 9748, the NTS types of RFC 8915 and 0x2005's UDP Checksum Complement, says "Private or Experimental Use" for the 0xF000-0xFFFF range RFC 9748 reserves, where chrony's 0xF323 falls, and "unknown" with the hex value otherwise), until what is left has a MAC's length; `mac`, an `NTPMAC(key_id, digest)` of 4 (a crypto-NAK, RFC 5905 §9.2), 20 (MD5 or AES-128-CMAC, RFC 8573) or 24 (SHA-1) bytes, or 12 below version 4 (RFC 1305's DES), never verified; and `remainder`, anything else, such as a field a snap length cut. A 4-byte Ethernet FCS after a plain message has a crypto-NAK's length and reads as one, which only the UDP length could prevent. Against a wrong port guess, `decode()` rejects a version outside 1-4 and then modes 6 and 7 (`InvalidFieldError`, `field` `"version"` or `"mode"`), which 62.5% of random datagrams fail; it judges the first byte before the length, so a message shorter than 48 bytes raises `TruncatedHeaderError` only when its first byte passes, or when it is empty. Construction accepts them. Mode 6 and 7 control messages (RFC 9327's mode 6 from `ntpq`, ntpd's private mode 7 from `ntpdc`) have their own header and are deliberately not decoded, so a strict walk raises `InvalidFieldError` on that traffic, whatever its length; walk it with `lax=True`, which stops at UDP with `stopped_by` set. A typed way to take port 123 out of dispatch for one walk, and decoding mode 6, are planned follow-ups. NTPv1 (RFC 1059) shares the header only by position: it has no Mode field, so its messages decode as mode 0, reserved.
19
37
  - **`HSRP`: Cisco's Hot Standby Router Protocol, HSRPv1 (RFC 2281) (#195).** UDP port 1985 dispatches, best-effort like every port entry, to the new `netprotocols.HSRP` (also `netprotocols.layer7.HSRP`), which decodes the 20-byte message (RFC 2281 §5.1) into `version`, `opcode`, `state`, `hellotime`, `holdtime`, `priority`, `group`, `reserved`, `auth_data` (the 8 bytes of clear-text Authentication Data, raw) and `virtual_ip` (dotted-decimal), so `bytes(HSRP.decode(x)) == x[:20]`; Hello, Coup and Resign share the layout and differ in the Op Code alone. Display properties: `opcode_name` and `state_name` (Initial, Learn, Listen, Speak, Standby, Active; `"unknown (n)"` for a value only construction can give), `password` (the Authentication Data with its NUL padding stripped, `b"cisco"` by default), `auth_data_str` (that text when printable, hex otherwise), `is_default_auth` and `virtual_mac`, the group's well-known `00:00:0c:07:ac:XX` address (§6.1). The message is always 20 bytes, so whatever follows it, an FCS or the MD5 Authentication TLV some Cisco releases append (not decoded), is left undecoded. Against a wrong port guess, `decode()` rejects a Version other than 0, an Op Code other than 0-2 and a State §5.1 does not list (`InvalidFieldError`, judged in that order before the length), which random bytes pass 18 times in 2^24; construction accepts them. The guard is also what keeps two other kinds of message on port 1985 from being misread as HSRPv1: HSRPv2, whose first byte is a TLV type, is rejected at `version`, and Cisco's Op Code 3 interface-state Advertise at `opcode`. Neither is decoded, so a strict walk raises on them: walk with `lax=True`. Decoding HSRPv2 is a possible follow-up.
@@ -51,7 +51,7 @@ src/netprotocols/
51
51
  GRE, IPsec ESP and AH, VRRP, OSPFv2, PIM
52
52
  layer4/ TCP, UDP, SCTP (+ port-based dispatch)
53
53
  layer7/ DNS, DHCP, HTTP, VXLAN, RIP, BGP, TFTP, RADIUS, STUN, NTP,
54
- HSRP
54
+ HSRP, TLS
55
55
  utils/ exceptions, MAC/IPv4 helpers
56
56
  tests/ unit tests, corpus invariants, fuzzing, fixtures/
57
57
  scripts/ fixture capture + validation
@@ -123,6 +123,15 @@ Fixtures are captured and validated with the scripts under
123
123
  python3 scripts/check_fixtures.py tests/fixtures/staging
124
124
  ```
125
125
 
126
+ `tests/fixtures/staging/` is an untracked, git-ignored workspace: capture
127
+ there, validate with `check_fixtures.py`, then copy the validated pcap
128
+ and any other files the capture script names into `tests/fixtures/` or
129
+ the subdirectory it names, and commit only those:
130
+ `capture_fixtures_http.sh`, for instance, has both `staging/http.pcap`
131
+ and the per-connection stream files it writes under `staging/http/`
132
+ folded into `tests/fixtures/http/`. Nothing under `staging/` is
133
+ committed.
134
+
126
135
  Prefer a real capture, and build the conditions for one where a
127
136
  protocol won't appear in a normal environment. VLAN tags, for instance,
128
137
  are stripped by a switch on an access port, so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: netprotocols
3
- Version: 2.7.0
3
+ Version: 2.8.0
4
4
  Summary: Low-level implementations of common networking protocols
5
5
  Project-URL: Homepage, https://github.com/EONRaider/NETProtocols
6
6
  Project-URL: Repository, https://github.com/EONRaider/NETProtocols
@@ -76,44 +76,46 @@ Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
76
76
 
77
77
  ## Why NETProtocols
78
78
 
79
- Measured against `dpkt 1.9.8` and `scapy 2.8.0` on the 379 frames of
80
- real captured traffic the corpus held at 2.7.0 (CPython 3.12.12, x86-64
81
- Linux; re-measured for 2.7.0 on 2026-11-03/04 — see
79
+ Measured against `dpkt 1.9.8` and `scapy 2.8.0` on the 394 frames of
80
+ real captured traffic the corpus held at 2.8.0 (CPython 3.12.12, x86-64
81
+ Linux; re-measured for 2.8.0 on 2026-11-04 — see
82
82
  [docs/CLAIMS.md](docs/CLAIMS.md) for every number below, its
83
83
  reproduction command, and its caveats):
84
84
 
85
- - **3.4× faster than scapy on decode; dpkt is 1.4× faster** —
85
+ - **3.3× faster than scapy on decode; dpkt is 1.5× faster** —
86
86
  `uv run --group bench python scripts/benchmark.py --compare`. dpkt is
87
87
  the faster of the two comparators. The routing captures 2.5.0 added
88
88
  (OSPF, BGP, PIM, RIP, VRRP) are where it pulls ahead, taking about
89
89
  three fifths of netprotocols' time on them. On the 140 frames the
90
- corpus held at 2.4.0 dpkt is ahead by a median 1.12× across runs. On
90
+ corpus held at 2.4.0 dpkt is ahead by a median 1.21× across runs. On
91
91
  the MPLS and LLC/SNAP captures 2.6.0 added they are level; dpkt
92
92
  leaves LLDP undecoded, so it is about 5× faster on those six frames,
93
93
  and it stops at UDP (at TCP for STUN's two TCP segments) on the TFTP,
94
94
  RADIUS, STUN, NTP and HSRP captures 2.7.0 added, where it is about
95
- 1.5× faster. Narrowing the gap is
95
+ 1.5× faster, and at TCP on the TLS capture 2.8.0 added, where it is
96
+ about 2× faster. Narrowing the gap is
96
97
  [#250](https://github.com/EONRaider/NETProtocols/issues/250). The gap
97
98
  moves both ways as this library and dpkt each change, and this file's
98
99
  job is to say so honestly rather than only when it's flattering.
99
- - **Decodes further into the stack than dpkt on 153 of those 379
100
- frames** — BGP, RIP, LLDP, DNS, DHCP, NTP, RADIUS, STUN, HSRP, TFTP
101
- and VXLAN-tunnelled payloads that dpkt leaves as raw bytes (dpkt
100
+ - **Decodes further into the stack than dpkt on 175 of those 394
101
+ frames** — BGP, RIP, LLDP, DNS, DHCP, NTP, RADIUS, STUN, HSRP, TFTP,
102
+ TLS and VXLAN-tunnelled payloads that dpkt leaves as raw bytes (dpkt
102
103
  ships decoders for several of them, but its UDP and TCP never dispatch
103
104
  on a port), and 802.1Q tags, MPLS label stacks and
104
105
  SNAP headers it folds into the Ethernet and LLC headers, netprotocols
105
106
  decodes as layers of their own. A throughput number means little
106
107
  without knowing how much work it bought.
107
- - **4.1× faster than dpkt at re-encoding** (`bytes(header)`,
108
+ - **1.3× faster than dpkt at re-encoding** a whole Ethernet/IPv4/UDP
109
+ frame (`bytes(packet)` against `bytes(dpkt.ethernet.Ethernet(frame))`,
108
110
  `scripts/benchmark_encode.py`) — the other half of "codec" that
109
111
  nobody else benchmarks.
110
- - **Imports in 57 ms; `scapy.all` takes about 240 ms** (`scripts/benchmark_import.py`,
112
+ - **Imports in 55 ms; `scapy.all` takes about 235 ms** (`scripts/benchmark_import.py`,
111
113
  with cached bytecode, as after `pip install` or after the first import;
112
- the first import after a source checkout compiles its modules and takes longer, 99 ms
113
- against 552 ms), and never touches the host doing it — importing scapy
114
+ the first import after a source checkout compiles its modules and
115
+ takes longer), and never touches the host doing it — importing scapy
114
116
  populates live interface and routing tables as a side effect of the import
115
117
  statement.
116
- - **A 266.9 KiB wheel against scapy's 2.63 MiB** — 10.1× smaller.
118
+ - **A 285.1 KiB wheel against scapy's 2.63 MiB** — 9.5× smaller.
117
119
  - **Regression-gated in CI, which — as far as we could establish by
118
120
  auditing ten comparable Python packet libraries' CI configurations
119
121
  (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark,
@@ -124,8 +126,10 @@ reproduction command, and its caveats):
124
126
  (construct disables it explicitly; stackforge's Criterion benches are
125
127
  never invoked). A fourth, PyTCP-net_proto, ships a benchmark too, but
126
128
  it exercises its RX daemon rather than the compared `net_proto`
127
- package, and isn't wired to CI either. None of the ten fails a build
128
- on a performance regression.
129
+ package, and isn't wired to CI either. A fifth, pcapkit, runs an
130
+ engine benchmark weekly in CI, never on a pull request, and reports
131
+ ratios without comparing them to an earlier run. None of the ten
132
+ fails a build on a performance regression (re-audited 2026-11-04).
129
133
  - **Typed where the alternatives are not.** `mypy --strict` over the
130
134
  whole of `src/`, enforced in CI. scapy ships `py.typed` but enables
131
135
  strict checking on 89 of its files, 2 of the 121 under
@@ -213,6 +217,7 @@ ip.proto → error: "IPv4" has no attribute
213
217
  | 7 | HSRP | `HSRP` | RFC 2281, HSRPv1 (version 0 on the wire) over UDP 1985; the 20-byte Hello, Coup and Resign message with every field raw, the Op Code and State named, the clear-text Authentication Data raw and unpadded (`password`), and the group's well-known virtual MAC address (`virtual_mac`); HSRPv2 and Cisco's Op Code 3 Advertise share the port and are rejected rather than misread as HSRPv1, so a strict walk raises `InvalidFieldError` on them: walk with `lax=True`; neither is decoded, nor is the MD5 Authentication TLV some releases append |
214
218
  | 7 | BGP-4 | `BGP` | RFC 4271, over TCP 179; one layer per message, chaining to the next in the same segment; OPEN fields and optional parameters, UPDATE withdrawn routes, path attributes (`BGPPathAttribute`, ORIGIN/NEXT_HOP/MED/LOCAL_PREF values decoded) and NLRI (`BGPPrefix`), NOTIFICATION codes; KEEPALIVE and ROUTE-REFRESH; no reassembly |
215
219
  | 7 | STUN | `STUN` | RFC 8489 (and RFC 5389, which it obsoletes), over UDP and TCP 3478; the 20-byte header with its magic cookie verified (RFC 3489 messages, which have none, are rejected), the message type split into class and method by accessors, attributes (`STUNAttribute`) parsed on demand with their 4-byte padding kept raw; MAPPED-ADDRESS and XOR-MAPPED-ADDRESS (`STUNAddress`, un-XORed for IPv4 and IPv6), SOFTWARE, ERROR-CODE, UNKNOWN-ATTRIBUTES; MESSAGE-INTEGRITY kept raw (it needs a key), FINGERPRINT compute/verify; over TCP one layer per message, chaining to the next in the same segment |
220
+ | 7 | TLS | `TLS`, `TLSClientHello`, `TLSExtension`, `peek_server_name`, `TLSServerNamePeek` | RFC 9846 §5.1 (TLS 1.3, obsoleting RFC 8446) and RFC 5246 §6.2 (TLS 1.2, also obsoleted by RFC 9846) record framing, over TCP 443; one layer per record (content type, legacy version, length, the fragment raw), chaining to the next in the same segment; the handshake type and, parsed on demand, the ClientHello (`TLSClientHello`, its extensions as `TLSExtension`) and its SNI host name (`server_name`, RFC 6066), read from a reassembled stream when the ClientHello spans segments, or, opt-in, peeked from its first segment with `peek_server_name()` (found, absent, or not reached, which is not "no SNI"); no crypto and no handshake beyond the ClientHello's fields: nothing is decrypted, and the ServerHello, certificates and every other extension stay raw; 853, 465, 993, 995 and 8443 need `decode_as`; no DTLS or QUIC |
216
221
  | 7 | HTTP/1.1 | `HTTP` | RFC 9112/9110; one message per decode from a buffer you reassembled; ambiguities reported as anomalies; not port-dispatched |
217
222
 
218
223
  ## Decoding a captured frame
@@ -549,7 +554,11 @@ and the fourth — TFTP, RADIUS, STUN, NTP and HSRP
549
554
  [#267](https://github.com/EONRaider/NETProtocols/pull/267),
550
555
  [#269](https://github.com/EONRaider/NETProtocols/pull/269),
551
556
  [#279](https://github.com/EONRaider/NETProtocols/pull/279)) — as
552
- 2.7.0 (see [CHANGELOG.md](CHANGELOG.md)).
557
+ 2.7.0, and the fifth — TLS record framing with SNI
558
+ ([#284](https://github.com/EONRaider/NETProtocols/pull/284),
559
+ [#290](https://github.com/EONRaider/NETProtocols/pull/290)) — as 2.8.0,
560
+ which completes the roadmap's protocol tiers (see
561
+ [CHANGELOG.md](CHANGELOG.md)).
553
562
 
554
563
  The roadmap before it, the post-1.3.0
555
564
  [#107](https://github.com/EONRaider/NETProtocols/issues/107), is
@@ -575,7 +584,7 @@ released as
575
584
  2.1.0's and 1.3.1/1.4.0's work shipped folded into 2.0.0 and 2.2.0
576
585
  rather than as their own versions, but the roadmap's original "only at
577
586
  a major version bump" release policy wasn't actually held to: 2.0.0,
578
- 2.2.0, 2.2.1, 2.3.0, 2.4.0, 2.5.0, 2.6.0 and now 2.7.0 are all on PyPI, and the comparative claims above
587
+ 2.2.0, 2.2.1, 2.3.0, 2.4.0, 2.5.0, 2.6.0, 2.7.0 and now 2.8.0 are all on PyPI, and the comparative claims above
579
588
  draw on the finished tree. The wave before that one — TCP
580
589
  and IPv4 option parsing, ICMP message bodies, NDP, IPv6
581
590
  extension-header TLV options, the GRE checksum arm, a DNS-over-TCP
@@ -588,7 +597,7 @@ Development uses [uv](https://docs.astral.sh/uv/): `uv sync`, then
588
597
  `uv run pytest`, `uv run mypy`, and `uv run ruff check` — all three are
589
598
  enforced by CI; the test suite runs across Python 3.12–3.14, while
590
599
  `mypy` and `ruff check` run on 3.12. The test suite is anchored by a
591
- 379-frame corpus of real captured traffic across 35 scenarios
600
+ 394-frame corpus of real captured traffic across 36 scenarios
592
601
  ([tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md), which says
593
602
  which layers a script built rather than the kernel) plus
594
603
  property-based fuzzing of the decode path. See
@@ -51,44 +51,46 @@ Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
51
51
 
52
52
  ## Why NETProtocols
53
53
 
54
- Measured against `dpkt 1.9.8` and `scapy 2.8.0` on the 379 frames of
55
- real captured traffic the corpus held at 2.7.0 (CPython 3.12.12, x86-64
56
- Linux; re-measured for 2.7.0 on 2026-11-03/04 — see
54
+ Measured against `dpkt 1.9.8` and `scapy 2.8.0` on the 394 frames of
55
+ real captured traffic the corpus held at 2.8.0 (CPython 3.12.12, x86-64
56
+ Linux; re-measured for 2.8.0 on 2026-11-04 — see
57
57
  [docs/CLAIMS.md](docs/CLAIMS.md) for every number below, its
58
58
  reproduction command, and its caveats):
59
59
 
60
- - **3.4× faster than scapy on decode; dpkt is 1.4× faster** —
60
+ - **3.3× faster than scapy on decode; dpkt is 1.5× faster** —
61
61
  `uv run --group bench python scripts/benchmark.py --compare`. dpkt is
62
62
  the faster of the two comparators. The routing captures 2.5.0 added
63
63
  (OSPF, BGP, PIM, RIP, VRRP) are where it pulls ahead, taking about
64
64
  three fifths of netprotocols' time on them. On the 140 frames the
65
- corpus held at 2.4.0 dpkt is ahead by a median 1.12× across runs. On
65
+ corpus held at 2.4.0 dpkt is ahead by a median 1.21× across runs. On
66
66
  the MPLS and LLC/SNAP captures 2.6.0 added they are level; dpkt
67
67
  leaves LLDP undecoded, so it is about 5× faster on those six frames,
68
68
  and it stops at UDP (at TCP for STUN's two TCP segments) on the TFTP,
69
69
  RADIUS, STUN, NTP and HSRP captures 2.7.0 added, where it is about
70
- 1.5× faster. Narrowing the gap is
70
+ 1.5× faster, and at TCP on the TLS capture 2.8.0 added, where it is
71
+ about 2× faster. Narrowing the gap is
71
72
  [#250](https://github.com/EONRaider/NETProtocols/issues/250). The gap
72
73
  moves both ways as this library and dpkt each change, and this file's
73
74
  job is to say so honestly rather than only when it's flattering.
74
- - **Decodes further into the stack than dpkt on 153 of those 379
75
- frames** — BGP, RIP, LLDP, DNS, DHCP, NTP, RADIUS, STUN, HSRP, TFTP
76
- and VXLAN-tunnelled payloads that dpkt leaves as raw bytes (dpkt
75
+ - **Decodes further into the stack than dpkt on 175 of those 394
76
+ frames** — BGP, RIP, LLDP, DNS, DHCP, NTP, RADIUS, STUN, HSRP, TFTP,
77
+ TLS and VXLAN-tunnelled payloads that dpkt leaves as raw bytes (dpkt
77
78
  ships decoders for several of them, but its UDP and TCP never dispatch
78
79
  on a port), and 802.1Q tags, MPLS label stacks and
79
80
  SNAP headers it folds into the Ethernet and LLC headers, netprotocols
80
81
  decodes as layers of their own. A throughput number means little
81
82
  without knowing how much work it bought.
82
- - **4.1× faster than dpkt at re-encoding** (`bytes(header)`,
83
+ - **1.3× faster than dpkt at re-encoding** a whole Ethernet/IPv4/UDP
84
+ frame (`bytes(packet)` against `bytes(dpkt.ethernet.Ethernet(frame))`,
83
85
  `scripts/benchmark_encode.py`) — the other half of "codec" that
84
86
  nobody else benchmarks.
85
- - **Imports in 57 ms; `scapy.all` takes about 240 ms** (`scripts/benchmark_import.py`,
87
+ - **Imports in 55 ms; `scapy.all` takes about 235 ms** (`scripts/benchmark_import.py`,
86
88
  with cached bytecode, as after `pip install` or after the first import;
87
- the first import after a source checkout compiles its modules and takes longer, 99 ms
88
- against 552 ms), and never touches the host doing it — importing scapy
89
+ the first import after a source checkout compiles its modules and
90
+ takes longer), and never touches the host doing it — importing scapy
89
91
  populates live interface and routing tables as a side effect of the import
90
92
  statement.
91
- - **A 266.9 KiB wheel against scapy's 2.63 MiB** — 10.1× smaller.
93
+ - **A 285.1 KiB wheel against scapy's 2.63 MiB** — 9.5× smaller.
92
94
  - **Regression-gated in CI, which — as far as we could establish by
93
95
  auditing ten comparable Python packet libraries' CI configurations
94
96
  (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark,
@@ -99,8 +101,10 @@ reproduction command, and its caveats):
99
101
  (construct disables it explicitly; stackforge's Criterion benches are
100
102
  never invoked). A fourth, PyTCP-net_proto, ships a benchmark too, but
101
103
  it exercises its RX daemon rather than the compared `net_proto`
102
- package, and isn't wired to CI either. None of the ten fails a build
103
- on a performance regression.
104
+ package, and isn't wired to CI either. A fifth, pcapkit, runs an
105
+ engine benchmark weekly in CI, never on a pull request, and reports
106
+ ratios without comparing them to an earlier run. None of the ten
107
+ fails a build on a performance regression (re-audited 2026-11-04).
104
108
  - **Typed where the alternatives are not.** `mypy --strict` over the
105
109
  whole of `src/`, enforced in CI. scapy ships `py.typed` but enables
106
110
  strict checking on 89 of its files, 2 of the 121 under
@@ -188,6 +192,7 @@ ip.proto → error: "IPv4" has no attribute
188
192
  | 7 | HSRP | `HSRP` | RFC 2281, HSRPv1 (version 0 on the wire) over UDP 1985; the 20-byte Hello, Coup and Resign message with every field raw, the Op Code and State named, the clear-text Authentication Data raw and unpadded (`password`), and the group's well-known virtual MAC address (`virtual_mac`); HSRPv2 and Cisco's Op Code 3 Advertise share the port and are rejected rather than misread as HSRPv1, so a strict walk raises `InvalidFieldError` on them: walk with `lax=True`; neither is decoded, nor is the MD5 Authentication TLV some releases append |
189
193
  | 7 | BGP-4 | `BGP` | RFC 4271, over TCP 179; one layer per message, chaining to the next in the same segment; OPEN fields and optional parameters, UPDATE withdrawn routes, path attributes (`BGPPathAttribute`, ORIGIN/NEXT_HOP/MED/LOCAL_PREF values decoded) and NLRI (`BGPPrefix`), NOTIFICATION codes; KEEPALIVE and ROUTE-REFRESH; no reassembly |
190
194
  | 7 | STUN | `STUN` | RFC 8489 (and RFC 5389, which it obsoletes), over UDP and TCP 3478; the 20-byte header with its magic cookie verified (RFC 3489 messages, which have none, are rejected), the message type split into class and method by accessors, attributes (`STUNAttribute`) parsed on demand with their 4-byte padding kept raw; MAPPED-ADDRESS and XOR-MAPPED-ADDRESS (`STUNAddress`, un-XORed for IPv4 and IPv6), SOFTWARE, ERROR-CODE, UNKNOWN-ATTRIBUTES; MESSAGE-INTEGRITY kept raw (it needs a key), FINGERPRINT compute/verify; over TCP one layer per message, chaining to the next in the same segment |
195
+ | 7 | TLS | `TLS`, `TLSClientHello`, `TLSExtension`, `peek_server_name`, `TLSServerNamePeek` | RFC 9846 §5.1 (TLS 1.3, obsoleting RFC 8446) and RFC 5246 §6.2 (TLS 1.2, also obsoleted by RFC 9846) record framing, over TCP 443; one layer per record (content type, legacy version, length, the fragment raw), chaining to the next in the same segment; the handshake type and, parsed on demand, the ClientHello (`TLSClientHello`, its extensions as `TLSExtension`) and its SNI host name (`server_name`, RFC 6066), read from a reassembled stream when the ClientHello spans segments, or, opt-in, peeked from its first segment with `peek_server_name()` (found, absent, or not reached, which is not "no SNI"); no crypto and no handshake beyond the ClientHello's fields: nothing is decrypted, and the ServerHello, certificates and every other extension stay raw; 853, 465, 993, 995 and 8443 need `decode_as`; no DTLS or QUIC |
191
196
  | 7 | HTTP/1.1 | `HTTP` | RFC 9112/9110; one message per decode from a buffer you reassembled; ambiguities reported as anomalies; not port-dispatched |
192
197
 
193
198
  ## Decoding a captured frame
@@ -524,7 +529,11 @@ and the fourth — TFTP, RADIUS, STUN, NTP and HSRP
524
529
  [#267](https://github.com/EONRaider/NETProtocols/pull/267),
525
530
  [#269](https://github.com/EONRaider/NETProtocols/pull/269),
526
531
  [#279](https://github.com/EONRaider/NETProtocols/pull/279)) — as
527
- 2.7.0 (see [CHANGELOG.md](CHANGELOG.md)).
532
+ 2.7.0, and the fifth — TLS record framing with SNI
533
+ ([#284](https://github.com/EONRaider/NETProtocols/pull/284),
534
+ [#290](https://github.com/EONRaider/NETProtocols/pull/290)) — as 2.8.0,
535
+ which completes the roadmap's protocol tiers (see
536
+ [CHANGELOG.md](CHANGELOG.md)).
528
537
 
529
538
  The roadmap before it, the post-1.3.0
530
539
  [#107](https://github.com/EONRaider/NETProtocols/issues/107), is
@@ -550,7 +559,7 @@ released as
550
559
  2.1.0's and 1.3.1/1.4.0's work shipped folded into 2.0.0 and 2.2.0
551
560
  rather than as their own versions, but the roadmap's original "only at
552
561
  a major version bump" release policy wasn't actually held to: 2.0.0,
553
- 2.2.0, 2.2.1, 2.3.0, 2.4.0, 2.5.0, 2.6.0 and now 2.7.0 are all on PyPI, and the comparative claims above
562
+ 2.2.0, 2.2.1, 2.3.0, 2.4.0, 2.5.0, 2.6.0, 2.7.0 and now 2.8.0 are all on PyPI, and the comparative claims above
554
563
  draw on the finished tree. The wave before that one — TCP
555
564
  and IPv4 option parsing, ICMP message bodies, NDP, IPv6
556
565
  extension-header TLV options, the GRE checksum arm, a DNS-over-TCP
@@ -563,7 +572,7 @@ Development uses [uv](https://docs.astral.sh/uv/): `uv sync`, then
563
572
  `uv run pytest`, `uv run mypy`, and `uv run ruff check` — all three are
564
573
  enforced by CI; the test suite runs across Python 3.12–3.14, while
565
574
  `mypy` and `ruff check` run on 3.12. The test suite is anchored by a
566
- 379-frame corpus of real captured traffic across 35 scenarios
575
+ 394-frame corpus of real captured traffic across 36 scenarios
567
576
  ([tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md), which says
568
577
  which layers a script built rather than the kernel) plus
569
578
  property-based fuzzing of the decode path. See
@@ -58,6 +58,13 @@ until request/response pairing exists.
58
58
  holds; reassembling the stream, and the segment-overlap tricks that
59
59
  come with it, are the caller's in the same way.
60
60
 
61
+ `TLS` is port-dispatched the same way and decodes the records one
62
+ segment holds; a ClientHello that spans segments is read only from a
63
+ stream the caller reassembled. It decrypts and verifies nothing: the
64
+ SNI it reports is what the client sent in the clear (under Encrypted
65
+ Client Hello, the outer ClientHello's public name), which identifies
66
+ nothing on its own.
67
+
61
68
  `MPLS` names the packet under its label stack by convention, since
62
69
  nothing in the stack names it: an Explicit NULL bottom label, or else
63
70
  the first nibble after the stack read as an IP version. A pseudowire