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.
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.gitignore +2 -1
- {netprotocols-2.7.0 → netprotocols-2.8.0}/ARCHITECTURE.md +124 -19
- {netprotocols-2.7.0 → netprotocols-2.8.0}/CHANGELOG.md +19 -1
- {netprotocols-2.7.0 → netprotocols-2.8.0}/CONTRIBUTING.md +10 -1
- {netprotocols-2.7.0 → netprotocols-2.8.0}/PKG-INFO +29 -20
- {netprotocols-2.7.0 → netprotocols-2.8.0}/README.md +28 -19
- {netprotocols-2.7.0 → netprotocols-2.8.0}/SECURITY.md +7 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/docs/CLAIMS.md +540 -86
- {netprotocols-2.7.0 → netprotocols-2.8.0}/pyproject.toml +2 -2
- netprotocols-2.8.0/scripts/benchmark_encode.py +133 -0
- netprotocols-2.8.0/scripts/capture_fixtures_tls.sh +565 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/check_fixtures.py +201 -6
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/extract_http_streams.py +4 -1
- netprotocols-2.8.0/scripts/survey_protocols.py +783 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/__init__.py +13 -1
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_base.py +6 -4
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_defaults.py +9 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/__init__.py +12 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/stun.py +6 -4
- netprotocols-2.8.0/src/netprotocols/layer7/tls.py +1354 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/walk.py +34 -17
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/conftest.py +32 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/MANIFEST.md +86 -8
- netprotocols-2.8.0/tests/fixtures/tls/tls_session.pcap +0 -0
- netprotocols-2.8.0/tests/fixtures/tls.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fuzz/fuzz_decode_frame.py +8 -4
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/strategies.py +58 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_bgp.py +28 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_contract.py +2 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_corpus.py +53 -1
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_dns.py +2 -2
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_docs.py +1 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_fuzz.py +335 -1
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_stun.py +3 -2
- netprotocols-2.8.0/tests/test_tls.py +2525 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/uv.lock +1 -1
- netprotocols-2.7.0/scripts/benchmark_encode.py +0 -107
- netprotocols-2.7.0/tests/fixtures/staging/ah.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/arp_exchange.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/bgp/bgp_session.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/bgp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/dhcp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/dns_tcp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/esp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/gre.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/hsrp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/http.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/icmpv4_external.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/igmp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/ipip.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/ipv6_mld.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/llc.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/lldp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/mpls/mpls_snaplen.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/mpls.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/ntp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/ospf.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/pim.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/radius.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/rip.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/staging/sctp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/stun/stun_rfc3489.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/stun.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/tcp_http.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/tcp_https.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/tftp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/udp_dns.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/vlan_icmp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/vrrp.pcap +0 -0
- netprotocols-2.7.0/tests/fixtures/vxlan.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.claude/skills/steward/SKILL.md +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.gitattributes +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/CODEOWNERS +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/dependabot.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/pull_request_template.md +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/ci.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/codeql.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/docs-lint.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/fuzz.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/mutation.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/pr-title-lint.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/release.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/.github/workflows/scorecard.yml +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/LICENSE +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/benchmarks/baseline.json +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/_pcap.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/benchmark.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/benchmark_import.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_bgp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_dhcp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_dns_tcp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_gre.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_hsrp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_http.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_igmp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ipip.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ipsec.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_llc.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_lldp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_mpls.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ntp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_ospf.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_pim.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_radius.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_rip.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_sctp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_stun.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_supplement.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_tftp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_vlan.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_vrrp.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/capture_fixtures_vxlan.sh +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/check_mutmut_survivors.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/filter_codeql_sarif.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/ipsec_fixture_packets.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/llc_fixture_frames.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/mpls_fixture_packets.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/mutmut_survivors.txt +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/check_in_pyodide.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/package-lock.json +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/package.json +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/scripts/pyodide/run_in_pyodide.mjs +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_enums.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/_tlv.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/checksum.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/flow.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/__init__.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/arp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/cdp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/ethernet.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/llc.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/lldp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/mpls.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/stp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer2/vlan.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/__init__.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ah.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/esp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/gre.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/icmp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/igmp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ip.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ipv6_ext.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/ospf.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/pim.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer3/vrrp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/__init__.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/_ports.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/sctp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/tcp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer4/udp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/bgp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/dhcp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/dns.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/hsrp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/http.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/ntp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/radius.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/rip.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/tftp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/layer7/vxlan.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/packet.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/pcap.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/py.typed +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/registry.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/__init__.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/exceptions.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/ipv4.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/src/netprotocols/utils/mac.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ah.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/arp_exchange.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/bgp/bgp_session.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/bgp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/codeql/python.sarif +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/dhcp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/dns_tcp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/esp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/gre.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/hsrp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/01.request +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/01.response +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/02.request +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/02.response +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/03.request +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/03.response +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/04.request +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/04.response +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/05.request +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/05.response +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/06.request +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/06.response +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/07.request +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/07.response +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/http/http.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv4_external.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/igmp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipip.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv6_mld.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/llc.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/lldp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/mpls/mpls_snaplen.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/mpls.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ntp.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/ospf.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/pim.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/radius.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/rip.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fixtures/sctp.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/stun/stun_rfc3489.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/stun.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/tcp_http.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/tcp_https.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/tftp.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/udp_dns.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/vlan_icmp.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/vrrp.pcap +0 -0
- {netprotocols-2.7.0/tests/fixtures/staging → netprotocols-2.8.0/tests/fixtures}/vxlan.pcap +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/fuzz/fuzz_http_decode.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ah.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_arp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_cdp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_checksum.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_dhcp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_diagnostics.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_enums.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_esp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ethernet.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_exports.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_extract_http_streams.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_filter_codeql_sarif.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_flow.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_gre.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_hsrp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_http.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_icmp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_igmp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ip.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ipip.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ipv6_ext.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ipv6_routing_final_dst.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_llc.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_lldp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_mpls.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_mutmut_gate.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ntp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_ospf.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_packet.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_pattern_matching.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_pcap.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_pim.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_radius.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_registry.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_rip.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_sctp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_stp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_tcp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_tftp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_tlv.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_udp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_utils.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_version.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_vlan.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_vrrp.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_vxlan.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_walk.py +0 -0
- {netprotocols-2.7.0 → netprotocols-2.8.0}/tests/test_workflows.py +0 -0
|
@@ -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
|
|
403
|
-
from decode context rather than from a wire field alone. A BGP
|
|
404
|
-
carries no payload: a TCP segment can hold several messages
|
|
405
|
-
back, each decoded as its own layer, so `BGP.next_protocol()`
|
|
406
|
-
next message *beside* it — `BGP` when the bytes after this
|
|
407
|
-
begin another one, `None` when they do not. STUN over TCP is
|
|
408
|
-
case, and `STUN.next_protocol()` answers `STUN` the same way,
|
|
409
|
-
8 bytes after the message begin like a message's header, its
|
|
410
|
-
cookie in place
|
|
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
|
|
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)
|
|
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`
|
|
920
|
-
`
|
|
921
|
-
|
|
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
|
|
1086
|
-
│ TLV walker
|
|
1087
|
-
├── _enums.py EtherType, IPProtocol, ARPOperation
|
|
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`
|
|
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.
|
|
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
|
|
80
|
-
real captured traffic the corpus held at 2.
|
|
81
|
-
Linux; re-measured for 2.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
113
|
-
|
|
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
|
|
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.
|
|
128
|
-
on a
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
55
|
-
real captured traffic the corpus held at 2.
|
|
56
|
-
Linux; re-measured for 2.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
|
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.
|
|
103
|
-
on a
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|