srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/include
diff options
context:
space:
mode:
Diffstat (limited to 'include')
-rw-r--r--include/packeteer/l7/dissector.hpp11
-rw-r--r--include/packeteer/l7/quic.hpp143
-rw-r--r--include/packeteer/summarize.hpp9
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;