diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..ac6adaa --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,67 @@ +name: Proto Fuzz Smoke + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`. + CARGO_FUZZ_VERSION: 0.13.1 + +jobs: + fuzz-smoke: + name: cargo fuzz smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install stable Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + with: + cache-key: fuzz + + - name: Install nightly Rust toolchain + # Installed alongside, but deliberately not made the directory + # default: cargo-fuzz itself is built with the default stable + # toolchain below, and only `fuzz build`/`fuzz run` are run with + # `+nightly`. Building cargo-fuzz's own dependency tree under a + # nightly override can trip build scripts (e.g. rustix) that gate + # unstable internal attributes on detecting a nightly compiler. + run: rustup toolchain install nightly --profile minimal --no-self-update + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}" + + - name: Build all fuzz targets + run: cargo +nightly fuzz build + working-directory: fuzz + + - name: Run bounded fuzz smoke for every target + # A single job building once and running each target sequentially + # avoids recompiling the fuzz workspace per target. Ten seconds per + # target is enough to catch a broken harness or an obvious + # regression; this is not meant to be exhaustive fuzzing. + run: | + set -euo pipefail + for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do + echo "::group::fuzz smoke: ${target}" + cargo +nightly fuzz run "${target}" -- \ + -max_total_time=10 \ + -timeout=5 + echo "::endgroup::" + done + working-directory: fuzz diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..ce0e380 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,10 @@ +target +artifacts +coverage + +# The committed seed corpus under corpus/ is small and curated by hand; a +# local `cargo fuzz run` grows each target's corpus directory far beyond +# that, so ignore anything not already tracked instead of the directory +# itself. +corpus/*/* +!corpus/*/ diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..8cbb037 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,246 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "irtt-proto" +version = "0.5.0" +dependencies = [ + "hmac", + "md-5", + "subtle", + "thiserror", +] + +[[package]] +name = "irtt-proto-fuzz" +version = "0.0.0" +dependencies = [ + "irtt-proto", + "libfuzzer-sys", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..56ee3fc --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "irtt-proto-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.irtt-proto] +path = "../crates/irtt-proto" + +# Use independent workspace for fuzzers +[workspace] +members = ["."] + +[[bin]] +name = "decode_request" +path = "fuzz_targets/decode_request.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_open_reply" +path = "fuzz_targets/decode_open_reply.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_echo_reply" +path = "fuzz_targets/decode_echo_reply.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_params" +path = "fuzz_targets/decode_params.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_varint" +path = "fuzz_targets/decode_varint.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..2692edf --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,77 @@ +# `irtt-proto` fuzzing + +Coverage-guided fuzzing of `irtt-proto`'s wire decoders via +[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer. + +This project is intentionally isolated from the normal stable/MSRV +workspace: it lives in its own `[workspace]` under `fuzz/`, is never a member +of the root workspace, requires nightly Rust, and is not built by +`cargo check`/`test`/`clippy`/`doc` at the workspace root. + +## Prerequisites + +- nightly Rust (`rustup toolchain install nightly`) +- `cargo-fuzz` (`cargo install cargo-fuzz --locked --version 0.13.1`) + +## Targets + +| Target | Exercises | +| ------------------- | ----------------------------------------------------------------- | +| `decode_request` | `decode_request`, `Params::decode` on a decoded Open, `verify_packet_hmac` | +| `decode_open_reply` | `decode_open_reply`, unauthenticated and HMAC-authenticated | +| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated | +| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip | +| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` | + +The invariant every target asserts is: **arbitrary bytes must never panic**. +Returning `Err` is normal and expected; a successful decode is also normal. + +Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so +libFuzzer spends its time exploring protocol structure rather than +allocating/copying megabytes — production IRTT datagrams are UDP-sized, well +under this bound. + +## Running + +```sh +cargo +nightly fuzz run decode_request +``` + +Bounded smoke run, e.g. 30 seconds: + +```sh +cargo +nightly fuzz run decode_request -- -max_total_time=30 +``` + +Build every target without running: + +```sh +cargo +nightly fuzz build +``` + +## Corpus + +Seed corpora live under `fuzz/corpus//`. They were generated once +from `irtt-proto`'s own public encoders (`encode_request`, `encode_open_reply`, +`encode_echo_reply`, `Params::encode`) by a throwaway local script, not copied +from any external source, and not derived from upstream `heistp/irtt`. Each +file is a small, representative wire packet (minimal Open, Close, Echo, HMAC +variants, ordinary/recv-count/recv-window/midpoint Echo replies, and a few +`Params` payloads including an unknown tag and `server_fill`). + +## Crash artifacts + +A failing case is written under `fuzz/artifacts//`. To replay it: + +```sh +cargo +nightly fuzz run decode_request fuzz/artifacts/decode_request/ +``` + +## Isolation notes + +- `fuzz/Cargo.toml` declares its own `[workspace]`, so it is never picked up + by the root `[workspace]` in `../Cargo.toml`. +- `libfuzzer-sys` and `arbitrary` are fuzz-only dependencies; they never + appear in any published crate's dependency graph. +- `fuzz/Cargo.lock` is committed for reproducible fuzzing builds, independent + of the root `Cargo.lock`. diff --git a/fuzz/corpus/decode_echo_reply/hmac_variant b/fuzz/corpus/decode_echo_reply/hmac_variant new file mode 100644 index 0000000..55f67a2 Binary files /dev/null and b/fuzz/corpus/decode_echo_reply/hmac_variant differ diff --git a/fuzz/corpus/decode_echo_reply/midpoint_variant b/fuzz/corpus/decode_echo_reply/midpoint_variant new file mode 100644 index 0000000..3afa5c5 Binary files /dev/null and b/fuzz/corpus/decode_echo_reply/midpoint_variant differ diff --git a/fuzz/corpus/decode_echo_reply/ordinary b/fuzz/corpus/decode_echo_reply/ordinary new file mode 100644 index 0000000..0bf3fa4 Binary files /dev/null and b/fuzz/corpus/decode_echo_reply/ordinary differ diff --git a/fuzz/corpus/decode_echo_reply/recv_count_variant b/fuzz/corpus/decode_echo_reply/recv_count_variant new file mode 100644 index 0000000..a5f2b9a Binary files /dev/null and b/fuzz/corpus/decode_echo_reply/recv_count_variant differ diff --git a/fuzz/corpus/decode_echo_reply/recv_window_variant b/fuzz/corpus/decode_echo_reply/recv_window_variant new file mode 100644 index 0000000..d81c790 Binary files /dev/null and b/fuzz/corpus/decode_echo_reply/recv_window_variant differ diff --git a/fuzz/corpus/decode_open_reply/hmac_reply b/fuzz/corpus/decode_open_reply/hmac_reply new file mode 100644 index 0000000..82d2341 --- /dev/null +++ b/fuzz/corpus/decode_open_reply/hmac_reply @@ -0,0 +1 @@ +§[ u9ü;dº%Íý¶Ä¢•oRq‡«¶–x€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/corpus/decode_open_reply/no_test_style_reply b/fuzz/corpus/decode_open_reply/no_test_style_reply new file mode 100644 index 0000000..43df184 Binary files /dev/null and b/fuzz/corpus/decode_open_reply/no_test_style_reply differ diff --git a/fuzz/corpus/decode_open_reply/normal_reply b/fuzz/corpus/decode_open_reply/normal_reply new file mode 100644 index 0000000..61378eb --- /dev/null +++ b/fuzz/corpus/decode_open_reply/normal_reply @@ -0,0 +1 @@ +§[Rq‡«¶–x€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/corpus/decode_params/empty b/fuzz/corpus/decode_params/empty new file mode 100644 index 0000000..e69de29 diff --git a/fuzz/corpus/decode_params/normal b/fuzz/corpus/decode_params/normal new file mode 100644 index 0000000..6d84b7f --- /dev/null +++ b/fuzz/corpus/decode_params/normal @@ -0,0 +1 @@ +€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/corpus/decode_params/repeated_known_tag b/fuzz/corpus/decode_params/repeated_known_tag new file mode 100644 index 0000000..024b4fa --- /dev/null +++ b/fuzz/corpus/decode_params/repeated_known_tag @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/corpus/decode_params/server_fill b/fuzz/corpus/decode_params/server_fill new file mode 100644 index 0000000..bfa93ac --- /dev/null +++ b/fuzz/corpus/decode_params/server_fill @@ -0,0 +1 @@ + pattern:abc \ No newline at end of file diff --git a/fuzz/corpus/decode_params/unknown_tag b/fuzz/corpus/decode_params/unknown_tag new file mode 100644 index 0000000..723f66f --- /dev/null +++ b/fuzz/corpus/decode_params/unknown_tag @@ -0,0 +1 @@ +cö \ No newline at end of file diff --git a/fuzz/corpus/decode_request/close b/fuzz/corpus/decode_request/close new file mode 100644 index 0000000..e71e93d --- /dev/null +++ b/fuzz/corpus/decode_request/close @@ -0,0 +1 @@ +§[ˆwfUD3" \ No newline at end of file diff --git a/fuzz/corpus/decode_request/echo b/fuzz/corpus/decode_request/echo new file mode 100644 index 0000000..95304bc Binary files /dev/null and b/fuzz/corpus/decode_request/echo differ diff --git a/fuzz/corpus/decode_request/hmac_echo b/fuzz/corpus/decode_request/hmac_echo new file mode 100644 index 0000000..69df430 Binary files /dev/null and b/fuzz/corpus/decode_request/hmac_echo differ diff --git a/fuzz/corpus/decode_request/hmac_open b/fuzz/corpus/decode_request/hmac_open new file mode 100644 index 0000000..b1cf58c --- /dev/null +++ b/fuzz/corpus/decode_request/hmac_open @@ -0,0 +1 @@ +§[ Àé ;¦w6�îñ†{‘â‚€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/corpus/decode_request/minimal_open b/fuzz/corpus/decode_request/minimal_open new file mode 100644 index 0000000..59ad7e4 --- /dev/null +++ b/fuzz/corpus/decode_request/minimal_open @@ -0,0 +1 @@ +§[ \ No newline at end of file diff --git a/fuzz/corpus/decode_request/no_test_open b/fuzz/corpus/decode_request/no_test_open new file mode 100644 index 0000000..afacd4c --- /dev/null +++ b/fuzz/corpus/decode_request/no_test_open @@ -0,0 +1 @@ +§[€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/fuzz_targets/decode_echo_reply.rs b/fuzz/fuzz_targets/decode_echo_reply.rs new file mode 100644 index 0000000..8100af9 --- /dev/null +++ b/fuzz/fuzz_targets/decode_echo_reply.rs @@ -0,0 +1,133 @@ +#![no_main] + +use irtt_proto::{ + decode_echo_reply, echo_packet_len, hmac::compute_hmac_in_place, Clock, PacketLayout, Params, + ReceivedStats, StampAt, FLAG_HMAC, FLAG_REPLY, TIMESTAMP_SIZE, +}; +use libfuzzer_sys::fuzz_target; + +/// See `decode_request.rs` for the rationale behind this bound. +const MAX_INPUT_LEN: usize = 128 * 1024; + +/// Mirrors `irtt-proto`'s crate-private HMAC field offset, the same way the +/// crate's own external integration tests do. +const HMAC_OFFSET: usize = 4; + +const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-echo-reply-key"; + +/// Negotiated-length vocabulary around the boundaries that matter for this +/// decoder: zero, tiny, MTU-ish, and modestly larger. Deliberately bounded so +/// we fuzz the decoder rather than benchmark OOM handling. +const LENGTH_BUCKETS: &[i64] = &[ + 0, 1, 63, 64, 65, 127, 128, 129, 511, 512, 513, 1199, 1200, 1201, 1471, 1472, 1473, 1499, 1500, + 1501, 4096, 65507, +]; + +fn pick_received_stats(v: u8) -> ReceivedStats { + match v % 4 { + 0 => ReceivedStats::None, + 1 => ReceivedStats::Count, + 2 => ReceivedStats::Window, + _ => ReceivedStats::Both, + } +} + +fn pick_stamp_at(v: u8) -> StampAt { + match v % 5 { + 0 => StampAt::None, + 1 => StampAt::Send, + 2 => StampAt::Receive, + 3 => StampAt::Both, + _ => StampAt::Midpoint, + } +} + +fn pick_clock(v: u8) -> Clock { + match v % 4 { + 0 => Clock::Unspecified, + 1 => Clock::Wall, + 2 => Clock::Monotonic, + _ => Clock::Both, + } +} + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + // Need enough control bytes to derive Params before any packet bytes. + const CONTROL_LEN: usize = 6; + if data.len() < CONTROL_LEN { + return; + } + let (control, rest) = data.split_at(CONTROL_LEN); + + let received_stats = pick_received_stats(control[0]); + let stamp_at = pick_stamp_at(control[1]); + let clock = pick_clock(control[2]); + let length_bucket = LENGTH_BUCKETS[control[3] as usize % LENGTH_BUCKETS.len()]; + let hmac_present = control[4] & 1 == 1; + let use_compat_len = control[5] & 1 == 1; + + let params = Params { + protocol_version: 1, + length: length_bucket, + received_stats, + stamp_at, + clock, + ..Params::default() + }; + + let Ok(normal_len) = echo_packet_len(hmac_present, ¶ms) else { + return; + }; + + let layout = PacketLayout::echo(hmac_present, ¶ms); + let is_single_clock_midpoint = + stamp_at == StampAt::Midpoint && matches!(clock, Clock::Wall | Clock::Monotonic); + let target_len = if use_compat_len && is_single_clock_midpoint { + (layout.header_len() + TIMESTAMP_SIZE).max(normal_len) + } else { + normal_len + }; + + let flags = FLAG_REPLY | if hmac_present { FLAG_HMAC } else { 0 }; + let mut packet = Vec::with_capacity(target_len); + packet.extend_from_slice(&irtt_proto::MAGIC); + packet.push(flags); + if hmac_present { + packet.extend_from_slice(&[0u8; 16]); + } + // Fill the remainder of the negotiated length from the fuzz-provided + // tail, truncating or zero-padding to hit the target length exactly so + // the decoder's field parsing gets real coverage rather than bailing out + // on a length mismatch every time. + let body_len = target_len.saturating_sub(packet.len()); + if rest.len() >= body_len { + packet.extend_from_slice(&rest[..body_len]); + } else { + packet.extend_from_slice(rest); + packet.resize(target_len, 0); + } + packet.resize(target_len, 0); + + if hmac_present { + if compute_hmac_in_place(FUZZ_HMAC_KEY, &mut packet, HMAC_OFFSET).is_err() { + return; + } + let reply = decode_echo_reply(&packet, ¶ms, Some(FUZZ_HMAC_KEY)); + if let Ok(reply) = reply { + assert!(reply.payload.len() <= packet.len()); + } + } else { + let reply = decode_echo_reply(&packet, ¶ms, None); + if let Ok(reply) = reply { + assert!(reply.payload.len() <= packet.len()); + } + } + + // Also exercise the raw fuzz bytes directly against the same negotiated + // Params, unmodified and un-lengthened, purely for the no-panic + // invariant on genuinely arbitrary bytes. + let _ = decode_echo_reply(data, ¶ms, None); +}); diff --git a/fuzz/fuzz_targets/decode_open_reply.rs b/fuzz/fuzz_targets/decode_open_reply.rs new file mode 100644 index 0000000..116903a --- /dev/null +++ b/fuzz/fuzz_targets/decode_open_reply.rs @@ -0,0 +1,49 @@ +#![no_main] + +use irtt_proto::{ + decode_open_reply, hmac::compute_hmac_in_place, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY, +}; +use libfuzzer_sys::fuzz_target; + +/// See `decode_request.rs` for the rationale behind this bound. +const MAX_INPUT_LEN: usize = 128 * 1024; + +/// Offset of the HMAC field, right after the 4-byte header. `irtt-proto` +/// keeps the real constant crate-private; this mirrors it the same way the +/// crate's own external integration tests do. +const HMAC_OFFSET: usize = 4; +const HMAC_SIZE: usize = 16; + +const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-open-reply-key"; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + // Unauthenticated decode path: no panic is the invariant, Ok/Err both + // acceptable. + let _ = decode_open_reply(data, None); + + // Authenticated decode path against arbitrary bytes. Most inputs will + // fail on HMAC presence/verification before reaching the post-HMAC + // parser, which is expected and fine on its own. + let _ = decode_open_reply(data, Some(FUZZ_HMAC_KEY)); + + // To also give the post-HMAC parser sustained coverage, derive a packet + // that *will* authenticate: when the input is structurally capable of + // carrying an HMAC field and its flags already indicate HMAC, patch in a + // freshly-computed HMAC over the untouched body via the crate's own + // helper (never reimplemented here) and decode that. + if data.len() >= HMAC_OFFSET + HMAC_SIZE { + let flags = data[3]; + let looks_like_open_reply = + flags & FLAG_OPEN != 0 && flags & FLAG_REPLY != 0 && flags & FLAG_HMAC != 0; + if looks_like_open_reply { + let mut signed = data.to_vec(); + if compute_hmac_in_place(FUZZ_HMAC_KEY, &mut signed, HMAC_OFFSET).is_ok() { + let _ = decode_open_reply(&signed, Some(FUZZ_HMAC_KEY)); + } + } + } +}); diff --git a/fuzz/fuzz_targets/decode_params.rs b/fuzz/fuzz_targets/decode_params.rs new file mode 100644 index 0000000..5bba854 --- /dev/null +++ b/fuzz/fuzz_targets/decode_params.rs @@ -0,0 +1,46 @@ +#![no_main] + +use irtt_proto::Params; +use libfuzzer_sys::fuzz_target; + +/// See `decode_request.rs` for the rationale behind this bound. +const MAX_INPUT_LEN: usize = 128 * 1024; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + let decoded = Params::decode(data); + let decoded_with_presence = Params::decode_with_presence(data); + + // Both entry points share one parser, so when both succeed they must + // agree on the decoded values. + match (&decoded, &decoded_with_presence) { + (Ok(params), Ok(with_presence)) => { + assert_eq!(params, &with_presence.params); + } + (Ok(_), Err(_)) | (Err(_), Ok(_)) => { + panic!("decode and decode_with_presence disagreed on success for {data:02x?}"); + } + (Err(_), Err(_)) => {} + } + + // No panic is the primary invariant for malformed input; unknown tags are + // tolerated and repeated known tags are last-value-wins by design, so we + // do not assert a particular failure mode here. + if let Ok(params) = decoded { + let encoded = params.encode(); + // Re-encoding and decoding again must preserve the semantic value. + // Presence information does not round-trip through `encode` for + // absent/default fields, so we compare `Params` values, not the + // original wire bytes. + if let Ok(reencoded_params) = Params::decode(&encoded) { + assert_eq!(params, reencoded_params); + } else { + panic!( + "re-encoding a successfully decoded Params produced bytes that failed to decode" + ); + } + } +}); diff --git a/fuzz/fuzz_targets/decode_request.rs b/fuzz/fuzz_targets/decode_request.rs new file mode 100644 index 0000000..244b968 --- /dev/null +++ b/fuzz/fuzz_targets/decode_request.rs @@ -0,0 +1,45 @@ +#![no_main] + +use irtt_proto::{decode_request, verify_packet_hmac, DecodedRequestKind, Params}; +use libfuzzer_sys::fuzz_target; + +/// Keeps individual cases bounded so libFuzzer explores protocol structure +/// instead of spending time allocating/copying megabytes. This is UDP wire +/// protocol code; production datagrams are well under this bound. +const MAX_INPUT_LEN: usize = 128 * 1024; + +/// Fixed key used only to exercise the HMAC verification path. Never treated +/// as a secret. +const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-request-key"; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + // The core invariant: arbitrary bytes must never panic. Ok/Err are both + // normal outcomes. + let decoded = match decode_request(data) { + Ok(decoded) => decoded, + Err(_) => return, + }; + + match decoded.kind { + DecodedRequestKind::Open { params, .. } => { + // Structural Open decoding deliberately permits malformed + // parameter bytes; both Ok and Err from the secondary parser are + // acceptable, and a structurally decoded Open is not required to + // contain valid Params. + let _ = Params::decode(params); + } + DecodedRequestKind::Close { .. } | DecodedRequestKind::Echo { .. } => { + // No token/session policy belongs here; that's above irtt-proto. + } + } + + if decoded.hmac_present { + // Most arbitrary HMAC fields will fail authentication; both outcomes + // are acceptable, we're only exercising the verifier for panics. + let _ = verify_packet_hmac(FUZZ_HMAC_KEY, data); + } +}); diff --git a/fuzz/fuzz_targets/decode_varint.rs b/fuzz/fuzz_targets/decode_varint.rs new file mode 100644 index 0000000..014422b --- /dev/null +++ b/fuzz/fuzz_targets/decode_varint.rs @@ -0,0 +1,38 @@ +#![no_main] + +use irtt_proto::varint::{decode_uvarint, decode_varint, encode_uvarint, encode_varint}; +use libfuzzer_sys::fuzz_target; + +/// See `decode_request.rs` for the rationale behind this bound. Varint +/// grammar is trivial, but the bound is kept for consistency with the other +/// targets. +const MAX_INPUT_LEN: usize = 128 * 1024; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + if let Ok((value, used)) = decode_uvarint(data) { + assert!(used > 0); + assert!(used <= data.len()); + assert!(used <= 10); + + let mut encoded = Vec::new(); + encode_uvarint(value, &mut encoded); + // Canonical re-encoding need not reproduce noncanonical fuzz input, + // but it must decode back to the same value and consume exactly its + // own bytes. + assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len()))); + } + + if let Ok((value, used)) = decode_varint(data) { + assert!(used > 0); + assert!(used <= data.len()); + assert!(used <= 10); + + let mut encoded = Vec::new(); + encode_varint(value, &mut encoded); + assert_eq!(decode_varint(&encoded), Ok((value, encoded.len()))); + } +});