diff options
| author | srdusr <[email protected]> | 2025-11-16 22:17:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-11-16 22:17:00 +0200 |
| commit | 3af3e356d6fdf43e6772dc2e91b322f8e8148f62 (patch) | |
| tree | 3bc5697412a50818ef633caee151a01927b96053 /tests/test_quic.cpp | |
| parent | 407249eb5d654b5a951c43bc1722fd397d5d922c (diff) | |
| download | packeteer-3af3e356d6fdf43e6772dc2e91b322f8e8148f62.tar.gz packeteer-3af3e356d6fdf43e6772dc2e91b322f8e8148f62.zip | |
Add a cleartext-only QUIC dissector; fix a port-collision bug in L7Registry
QUIC decodes only what RFC 9000 sends in cleartext at the framing
level: long/short header form, version, long-packet type, and both
connection IDs. Everything past that is encrypted from the first
protected byte onward, even for Initial packets - decrypting that is
real crypto machinery this project deliberately doesn't take on, the
same call already made for TLS's SNI-only extraction.
Caught a real, previously-latent bug while wiring this in, by design
review rather than live-traffic debugging: L7Registry::dissect()
returned on the first dissector whose port() matched, even if that
dissector's summarize() then failed. Harmless while every registered
port was unique, but QUIC is the first protocol here to genuinely
share a well-known port with something already registered (443: TLS
over TCP, QUIC over UDP - the registry has no transport dimension).
Without the fix, tls_dissector would silently claim every port-443
lookup and QUIC would never be reachable. Fixed to try each same-port
dissector until one actually succeeds, locked in with stub-dissector
tests independent of any real protocol's parsing.
Live-verified thoroughly: real HTTP/3 traffic to google.com via
`curl --http3-only`, captured on wlp1s0, correctly decoding the full
connection lifecycle against Google's actual production QUIC
implementation - Initial packets (including a genuine connection ID
migration mid-handshake), Handshake packets, and 1-RTT short-header
packets. This also confirms the L7Registry fix live: without it none
of this would have decoded at all.
Diffstat (limited to 'tests/test_quic.cpp')
| -rw-r--r-- | tests/test_quic.cpp | 97 |
1 files changed, 97 insertions, 0 deletions
diff --git a/tests/test_quic.cpp b/tests/test_quic.cpp new file mode 100644 index 0000000..2366176 --- /dev/null +++ b/tests/test_quic.cpp @@ -0,0 +1,97 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "packeteer/l7/quic.hpp" + +using namespace packeteer::net; + +namespace { + +std::vector<unsigned char> long_header(std::uint8_t type_bits, std::uint32_t version, + std::vector<unsigned char> dcid, + std::vector<unsigned char> scid) { + std::vector<unsigned char> bytes; + bytes.push_back(static_cast<unsigned char>(0xC0 | (type_bits << 4))); // long form, fixed bit + bytes.push_back(static_cast<unsigned char>(version >> 24)); + bytes.push_back(static_cast<unsigned char>(version >> 16)); + bytes.push_back(static_cast<unsigned char>(version >> 8)); + bytes.push_back(static_cast<unsigned char>(version)); + bytes.push_back(static_cast<unsigned char>(dcid.size())); + bytes.insert(bytes.end(), dcid.begin(), dcid.end()); + bytes.push_back(static_cast<unsigned char>(scid.size())); + bytes.insert(bytes.end(), scid.begin(), scid.end()); + return bytes; +} + +} // namespace + +TEST_CASE("parse_quic decodes a long-header Initial packet's version and connection IDs") { + auto bytes = long_header(0x00, 0x00000001, {0xAA, 0xBB, 0xCC, 0xDD}, {0x11, 0x22}); + auto pkt = parse_quic(bytes); + REQUIRE(pkt.has_value()); + CHECK(pkt->is_long_header); + REQUIRE(pkt->long_header.has_value()); + CHECK(pkt->long_header->type == QuicLongPacketType::kInitial); + CHECK(pkt->long_header->version == 0x00000001); + CHECK(pkt->long_header->dcid == std::vector<unsigned char>{0xAA, 0xBB, 0xCC, 0xDD}); + CHECK(pkt->long_header->scid == std::vector<unsigned char>{0x11, 0x22}); +} + +TEST_CASE("parse_quic decodes each long-packet type from its type bits") { + CHECK(parse_quic(long_header(0x00, 1, {}, {}))->long_header->type == + QuicLongPacketType::kInitial); + CHECK(parse_quic(long_header(0x01, 1, {}, {}))->long_header->type == + QuicLongPacketType::kZeroRtt); + CHECK(parse_quic(long_header(0x02, 1, {}, {}))->long_header->type == + QuicLongPacketType::kHandshake); + CHECK(parse_quic(long_header(0x03, 1, {}, {}))->long_header->type == + QuicLongPacketType::kRetry); +} + +TEST_CASE("parse_quic treats version 0 as Version Negotiation regardless of type bits") { + auto pkt = parse_quic(long_header(0x02, 0x00000000, {0xAA}, {})); + REQUIRE(pkt.has_value()); + CHECK(pkt->long_header->type == QuicLongPacketType::kVersionNegotiation); +} + +TEST_CASE("parse_quic recognizes a short-header packet without decoding past the first byte") { + std::vector<unsigned char> bytes = {0x40, 0xAA, 0xBB, 0xCC}; // short form, fixed bit set + auto pkt = parse_quic(bytes); + REQUIRE(pkt.has_value()); + CHECK_FALSE(pkt->is_long_header); + CHECK_FALSE(pkt->long_header.has_value()); +} + +TEST_CASE("parse_quic rejects a packet with the Fixed Bit clear") { + std::vector<unsigned char> bytes = {0x00, 0xAA, 0xBB, 0xCC}; + CHECK_FALSE(parse_quic(bytes).has_value()); +} + +TEST_CASE("parse_quic rejects a long-header packet truncated before the version field") { + std::vector<unsigned char> bytes = {0xC0, 0x00, 0x00}; + CHECK_FALSE(parse_quic(bytes).has_value()); +} + +TEST_CASE("parse_quic rejects a long-header packet whose DCID length exceeds the buffer") { + std::vector<unsigned char> bytes = {0xC0, 0x00, 0x00, 0x00, 0x01, 20}; // claims 20-byte DCID + CHECK_FALSE(parse_quic(bytes).has_value()); +} + +TEST_CASE("QuicDissector claims port 443 and formats an Initial packet") { + QuicDissector dissector; + CHECK(dissector.port() == kQuicPort); + + auto bytes = long_header(0x00, 0x00000001, {0xAA, 0xBB}, {}); + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(*summary == "QUIC Initial v=0x00000001 dcid=aabb"); +} + +TEST_CASE("QuicDissector formats a short-header packet distinctly, without a fake dcid") { + QuicDissector dissector; + std::vector<unsigned char> bytes = {0x40, 0xAA, 0xBB, 0xCC}; + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(*summary == "QUIC 1-RTT (short header)"); +} |