diff options
Diffstat (limited to 'include')
| -rw-r--r-- | include/packeteer/l7/dissector.hpp | 11 | ||||
| -rw-r--r-- | include/packeteer/l7/quic.hpp | 143 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 9 |
3 files changed, 162 insertions, 1 deletions
diff --git a/include/packeteer/l7/dissector.hpp b/include/packeteer/l7/dissector.hpp index e918baf..b640fcc 100644 --- a/include/packeteer/l7/dissector.hpp +++ b/include/packeteer/l7/dissector.hpp @@ -30,10 +30,19 @@ class L7Registry { public: void add(const L7Dissector* dissector) { dissectors_.push_back(dissector); } + // Tries every dissector registered for `port`, not just the + // first: two different protocols can genuinely share a + // well-known port number when one runs over TCP and the other + // over UDP (443 is TLS/HTTPS over TCP *and* QUIC/HTTP3 over UDP) + // - this registry has no transport dimension, only a port + // number, so without this a dissector registered earlier for the + // same port would permanently shadow a later one the moment both + // exist, even on payloads the earlier one can't actually parse. std::optional<std::string> dissect(std::uint16_t port, std::span<const unsigned char> payload) const { for (const auto* dissector : dissectors_) { - if (dissector->port() == port) return dissector->summarize(payload); + if (dissector->port() != port) continue; + if (auto summary = dissector->summarize(payload)) return summary; } return std::nullopt; } diff --git a/include/packeteer/l7/quic.hpp b/include/packeteer/l7/quic.hpp new file mode 100644 index 0000000..9c9ac85 --- /dev/null +++ b/include/packeteer/l7/quic.hpp @@ -0,0 +1,143 @@ +#pragma once + +#include <cstdint> +#include <cstdio> +#include <optional> +#include <span> +#include <string> +#include <vector> + +#include "packeteer/byteio.hpp" +#include "packeteer/l7/dissector.hpp" + +// RFC 9000 QUIC, decoding only what's genuinely sent in cleartext at +// the framing level: whether a packet uses the long or short header +// form, its version and long-packet type (Initial/0-RTT/Handshake/ +// Retry/Version Negotiation), and the connection IDs. Packet numbers, +// frames, and the payload itself are encrypted from the very first +// protected byte onward - even for Initial packets, whose keys are +// derived via HKDF from a public per-version salt and then used for +// AES-GCM. Actually decrypting that is real, substantial crypto +// machinery this project deliberately doesn't take on, the same call +// already made for TLS: SNI is read because it's a plaintext +// extension in ClientHello; nothing past the handshake is ever +// decrypted there either. +// +// A short-header packet's destination connection ID has no length +// field in the packet itself - the receiver already knows it from +// earlier connection state (a length one endpoint chose and +// communicated during the handshake). A passive observer with no +// connection state genuinely cannot know where it ends, so +// short-header packets are reported by form alone, nothing decoded +// further. +namespace packeteer::net { + +inline constexpr std::uint16_t kQuicPort = 443; + +enum class QuicLongPacketType { + kVersionNegotiation, + kInitial, + kZeroRtt, + kHandshake, + kRetry, +}; + +struct QuicLongHeader { + QuicLongPacketType type; + std::uint32_t version; + std::vector<unsigned char> dcid; + std::vector<unsigned char> scid; +}; + +struct QuicPacket { + bool is_long_header; + std::optional<QuicLongHeader> long_header; // set only when is_long_header +}; + +inline std::optional<QuicPacket> parse_quic(std::span<const unsigned char> bytes) { + if (bytes.empty()) return std::nullopt; + + std::uint8_t first = bytes[0]; + if ((first & 0x40) == 0) return std::nullopt; // Fixed Bit must be 1 (RFC 9000 17.2/17.3) + + bool is_long = (first & 0x80) != 0; + if (!is_long) return QuicPacket{false, std::nullopt}; + + if (bytes.size() < 5) return std::nullopt; // header form byte + 4-byte version + std::uint32_t version = read_be32(bytes, 1); + + std::size_t pos = 5; + if (pos >= bytes.size()) return std::nullopt; + std::uint8_t dcid_len = bytes[pos++]; + if (pos + dcid_len > bytes.size()) return std::nullopt; + std::vector<unsigned char> dcid(bytes.begin() + pos, bytes.begin() + pos + dcid_len); + pos += dcid_len; + + if (pos >= bytes.size()) return std::nullopt; + std::uint8_t scid_len = bytes[pos++]; + if (pos + scid_len > bytes.size()) return std::nullopt; + std::vector<unsigned char> scid(bytes.begin() + pos, bytes.begin() + pos + scid_len); + + QuicLongPacketType type; + if (version == 0) { + // Version Negotiation (RFC 9000 17.2.1): the long-packet-type + // bits aren't meaningful here - this packet form predates + // that field's assignment and repurposes the whole byte. + type = QuicLongPacketType::kVersionNegotiation; + } else { + switch ((first >> 4) & 0x03) { + case 0: type = QuicLongPacketType::kInitial; break; + case 1: type = QuicLongPacketType::kZeroRtt; break; + case 2: type = QuicLongPacketType::kHandshake; break; + default: type = QuicLongPacketType::kRetry; break; + } + } + + return QuicPacket{true, QuicLongHeader{type, version, std::move(dcid), std::move(scid)}}; +} + +inline std::string quic_long_type_name(QuicLongPacketType type) { + switch (type) { + case QuicLongPacketType::kVersionNegotiation: return "Version Negotiation"; + case QuicLongPacketType::kInitial: return "Initial"; + case QuicLongPacketType::kZeroRtt: return "0-RTT"; + case QuicLongPacketType::kHandshake: return "Handshake"; + case QuicLongPacketType::kRetry: return "Retry"; + } + return "unknown"; // unreachable: every enumerator is handled above +} + +inline std::string quic_bytes_to_hex(std::span<const unsigned char> bytes) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string out; + out.reserve(bytes.size() * 2); + for (auto b : bytes) { + out += kHex[b >> 4]; + out += kHex[b & 0x0F]; + } + return out; +} + +class QuicDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kQuicPort; } + + std::optional<std::string> summarize(std::span<const unsigned char> payload) const override { + auto pkt = parse_quic(payload); + if (!pkt) return std::nullopt; + + if (!pkt->is_long_header) return std::string("QUIC 1-RTT (short header)"); + + const auto& h = *pkt->long_header; + std::string out = "QUIC " + quic_long_type_name(h.type); + if (h.type != QuicLongPacketType::kVersionNegotiation) { + char buf[16]; + std::snprintf(buf, sizeof(buf), " v=0x%08x", h.version); + out += buf; + } + out += " dcid=" + (h.dcid.empty() ? "(empty)" : quic_bytes_to_hex(h.dcid)); + return out; + } +}; + +} // namespace packeteer::net diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 66b2e18..57f6f6f 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -16,6 +16,7 @@ #include "packeteer/l7/http.hpp" #include "packeteer/l7/mdns.hpp" #include "packeteer/l7/ntp.hpp" +#include "packeteer/l7/quic.hpp" #include "packeteer/l7/smtp.hpp" #include "packeteer/l7/ssh.hpp" #include "packeteer/l7/tftp.hpp" @@ -109,6 +110,7 @@ inline const net::L7Registry& l7_registry() { static const net::FtpDissector ftp_dissector; static const net::SmtpDissector smtp_dissector; static const net::TftpDissector tftp_dissector; + static const net::QuicDissector quic_dissector; static const net::L7Registry registry = [] { net::L7Registry r; r.add(&dns_dissector); @@ -121,6 +123,13 @@ inline const net::L7Registry& l7_registry() { r.add(&ftp_dissector); r.add(&smtp_dissector); r.add(&tftp_dissector); + // Registered after tls_dissector deliberately: both claim + // port 443 (TLS over TCP, QUIC over UDP) - see + // L7Registry::dissect()'s fallback-on-no-match behavior for + // why registration order past the first claimant doesn't + // actually matter for correctness, just for which one gets + // tried first. + r.add(&quic_dissector); return r; }(); return registry; |