From 9b0f09a8a8aadf1ac2e0803455dbd66787c07df7 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:31:47 -0400 Subject: [PATCH] Add hostile-network tests, parser fuzzing, and CI fuzz job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track B (milestone 5 / §16 hardening): - tests/hostile_network.rs: in-process UDP relay/shim between a test client and a real dosh-server that can drop, reorder, and duplicate datagrams, and rebind its upstream socket mid-session. Asserts: session survives loss/reorder, duplicated/replayed Input is applied at most once, stale packets after resume are ignored (not fatal), and a client source-address change preserves the session. - tests/parser_robustness.rs: deterministic randomized tests throwing garbage at every reachable public parser (packet decode, from_body for all protocol/native structs, authorized_keys, known_hosts, host-key line, ssh-ed25519 blob, bootstrap, attach ticket); asserts none panic. - fuzz/: standalone cargo-fuzz crate (own [workspace], non-default member) with libfuzzer-sys harnesses for packet decode, from_body, authorized_keys, known_hosts, handshake structs+verifiers, and attach tickets. README documents the run command. - .github/workflows/ci.yml: add fuzz-smoke job (nightly + cargo-fuzz, short -max_total_time per target, tolerant if tooling unavailable); existing fmt/test/build/bench steps unchanged. cargo fmt --check and cargo test are green. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 32 + fuzz/.gitignore | 5 + fuzz/Cargo.toml | 64 ++ fuzz/README.md | 59 ++ fuzz/fuzz_targets/attach_ticket.rs | 20 + fuzz/fuzz_targets/authorized_keys.rs | 18 + fuzz/fuzz_targets/from_body.rs | 62 ++ fuzz/fuzz_targets/handshake_structs.rs | 44 ++ fuzz/fuzz_targets/known_hosts.rs | 14 + fuzz/fuzz_targets/packet_decode.rs | 23 + tests/hostile_network.rs | 781 +++++++++++++++++++++++++ tests/parser_robustness.rs | 415 +++++++++++++ 12 files changed, 1537 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/fuzz_targets/attach_ticket.rs create mode 100644 fuzz/fuzz_targets/authorized_keys.rs create mode 100644 fuzz/fuzz_targets/from_body.rs create mode 100644 fuzz/fuzz_targets/handshake_structs.rs create mode 100644 fuzz/fuzz_targets/known_hosts.rs create mode 100644 fuzz/fuzz_targets/packet_decode.rs create mode 100644 tests/hostile_network.rs create mode 100644 tests/parser_robustness.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b9266..0e1abe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,38 @@ jobs: - name: Docker SSH benchmark gate run: sh scripts/ci-docker-ssh-bench.sh + fuzz-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install nightly toolchain + id: nightly + continue-on-error: true + uses: dtolnay/rust-toolchain@nightly + - name: Install cargo-fuzz + id: install + if: steps.nightly.outcome == 'success' + continue-on-error: true + run: cargo install cargo-fuzz --locked + - name: Run fuzz targets briefly + if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success' + run: | + set -e + for target in \ + packet_decode \ + from_body \ + authorized_keys \ + known_hosts \ + handshake_structs \ + attach_ticket; do + echo "== fuzzing $target ==" + cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \ + -max_total_time=20 -rss_limit_mb=4096 + done + - name: Note when fuzzing was skipped + if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success' + run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run." + remote-bench: runs-on: ubuntu-latest env: diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..a854700 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,5 @@ +/target +/corpus +/artifacts +/coverage +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..9f50da8 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "dosh-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +# Standalone workspace so this crate is never absorbed by, and never affects, +# the main `dosh` crate's `cargo build` / `cargo test` / `cargo fmt --check`. +[workspace] + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.dosh] +path = ".." + +# cargo-fuzz needs unwinding to report panics; keep debug assertions on. +[profile.release] +debug = 1 + +[[bin]] +name = "packet_decode" +path = "fuzz_targets/packet_decode.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "from_body" +path = "fuzz_targets/from_body.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "authorized_keys" +path = "fuzz_targets/authorized_keys.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "known_hosts" +path = "fuzz_targets/known_hosts.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "handshake_structs" +path = "fuzz_targets/handshake_structs.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "attach_ticket" +path = "fuzz_targets/attach_ticket.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..8f57b28 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,59 @@ +# dosh fuzz targets + +cargo-fuzz / libFuzzer harnesses for the `dosh` parsers and handshake verifiers +(spec milestone 5 / §16: "Fuzz packet parsing, authorized-key parsing, +known-host parsing, and handshake state", "Fuzz targets run in CI"). + +This is a **standalone crate** (its own `[workspace]`) so it never affects the +main crate's `cargo build` / `cargo test` / `cargo fmt --check`. + +## Targets + +| Target | Parser(s) exercised | +| --- | --- | +| `packet_decode` | `protocol::Header::parse`, `protocol::decode`, `protocol::decrypt_body` | +| `from_body` | `protocol::from_body::` for every protocol & native wire struct | +| `authorized_keys` | `native::parse_authorized_keys`, `native::parse_ssh_ed25519_public_blob` | +| `known_hosts` | `native::parse_known_hosts`, `native::parse_host_public_key_line` | +| `handshake_structs` | handshake struct decode + `verify_server_hello`, `user_auth_transcript`, `verify_native_user_auth` | +| `attach_ticket` | `auth::open_attach_ticket`, `auth::verify_attach_ticket`, `auth::decode_bootstrap` | + +Every target's objective is the same: **no panics on any input** (a panic on +untrusted bytes is a robustness/DoS bug per threat model §5). + +## Prerequisites + +```sh +rustup toolchain install nightly +cargo install cargo-fuzz +``` + +cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`). + +## Run + +From the repository root: + +```sh +# List targets +cargo +nightly fuzz list --fuzz-dir fuzz + +# Run a single target indefinitely +cargo +nightly fuzz run --fuzz-dir fuzz packet_decode + +# Short, CI-style smoke run of one target (10 seconds) +cargo +nightly fuzz run --fuzz-dir fuzz packet_decode -- -max_total_time=10 +``` + +Or from inside `fuzz/`: + +```sh +cd fuzz +cargo +nightly fuzz run packet_decode -- -max_total_time=10 +``` + +## CI + +`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly + +cargo-fuzz and runs each target briefly (`-max_total_time`). The job is tolerant +if the toolchain/tooling is unavailable so it never blocks the main test gate. diff --git a/fuzz/fuzz_targets/attach_ticket.rs b/fuzz/fuzz_targets/attach_ticket.rs new file mode 100644 index 0000000..907f5f0 --- /dev/null +++ b/fuzz/fuzz_targets/attach_ticket.rs @@ -0,0 +1,20 @@ +#![no_main] +//! Fuzz the attach-ticket and bootstrap decoders. These open server-sealed +//! AEAD blobs and base64 bootstrap envelopes from cache / wire material that an +//! attacker may corrupt; none may panic. + +use libfuzzer_sys::fuzz_target; + +use dosh::auth::{decode_bootstrap, open_attach_ticket, verify_attach_ticket}; + +fuzz_target!(|data: &[u8]| { + let secret = [0x11u8; 32]; + let psk = [0x22u8; 32]; + + let _ = open_attach_ticket(&secret, data); + let _ = verify_attach_ticket(&secret, data, &psk, "default", "read-write"); + + if let Ok(text) = std::str::from_utf8(data) { + let _ = decode_bootstrap(text); + } +}); diff --git a/fuzz/fuzz_targets/authorized_keys.rs b/fuzz/fuzz_targets/authorized_keys.rs new file mode 100644 index 0000000..82c2a72 --- /dev/null +++ b/fuzz/fuzz_targets/authorized_keys.rs @@ -0,0 +1,18 @@ +#![no_main] +//! Fuzz the authorized_keys parser, including its option lexer and the +//! ssh-ed25519 public-key blob parser it depends on. None may panic. + +use libfuzzer_sys::fuzz_target; + +use dosh::native::{parse_authorized_keys, parse_ssh_ed25519_public_blob}; + +fuzz_target!(|data: &[u8]| { + // The blob parser operates directly on raw bytes. + let _ = parse_ssh_ed25519_public_blob(data); + + // The line parser operates on text; only feed valid UTF-8 (lossless), + // matching how the file is read in production via read_to_string. + if let Ok(text) = std::str::from_utf8(data) { + let _ = parse_authorized_keys(text); + } +}); diff --git a/fuzz/fuzz_targets/from_body.rs b/fuzz/fuzz_targets/from_body.rs new file mode 100644 index 0000000..118e8c4 --- /dev/null +++ b/fuzz/fuzz_targets/from_body.rs @@ -0,0 +1,62 @@ +#![no_main] +//! Fuzz `protocol::from_body` (bincode deserialization) for every protocol and +//! native struct that is decoded from untrusted wire bytes. None may panic. + +use libfuzzer_sys::fuzz_target; + +use dosh::auth::{AttachTicketPlain, BootstrapResponse, SealedAttachTicket}; +use dosh::native::{ + HostPublicKey, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth, +}; +use dosh::protocol::{ + self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Input, NativeAuthCheckOkBody, + NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, Resize, + ResumeRequest, StreamClose, StreamData, StreamEof, StreamOpen, StreamOpenOk, StreamOpenReject, + StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope, +}; + +macro_rules! try_body { + ($data:expr, $ty:ty) => { + let _ = protocol::from_body::<$ty>($data); + }; +} + +fuzz_target!(|data: &[u8]| { + // protocol.rs structs + try_body!(data, BootstrapAttachRequest); + try_body!(data, TicketAttachEnvelope); + try_body!(data, TicketAttachBody); + try_body!(data, TicketAttachOkEnvelope); + try_body!(data, AttachOk); + try_body!(data, AttachReject); + try_body!(data, ResumeRequest); + try_body!(data, Input); + try_body!(data, Resize); + try_body!(data, Frame); + try_body!(data, StreamOpen); + try_body!(data, StreamOpenOk); + try_body!(data, StreamOpenReject); + try_body!(data, StreamData); + try_body!(data, StreamWindowAdjust); + try_body!(data, StreamEof); + try_body!(data, StreamClose); + + // native handshake wrapper bodies + try_body!(data, NativeClientHelloBody); + try_body!(data, NativeServerHelloBody); + try_body!(data, NativeUserAuthBody); + try_body!(data, NativeAuthOkBody); + try_body!(data, NativeAuthCheckOkBody); + + // bare native handshake structs + try_body!(data, NativeClientHello); + try_body!(data, NativeServerHello); + try_body!(data, NativeUserAuth); + try_body!(data, NativeAuthOk); + try_body!(data, HostPublicKey); + + // auth.rs structs + try_body!(data, BootstrapResponse); + try_body!(data, SealedAttachTicket); + try_body!(data, AttachTicketPlain); +}); diff --git a/fuzz/fuzz_targets/handshake_structs.rs b/fuzz/fuzz_targets/handshake_structs.rs new file mode 100644 index 0000000..6ec444e --- /dev/null +++ b/fuzz/fuzz_targets/handshake_structs.rs @@ -0,0 +1,44 @@ +#![no_main] +//! Fuzz the native handshake structs and their structural verifiers. +//! +//! Beyond plain deserialization (covered by the `from_body` target), this drives +//! the verifier state machine: if the input happens to decode into the handshake +//! structs, run `verify_server_hello`, `user_auth_transcript`, and +//! `verify_native_user_auth` on them. These run on attacker-controlled material +//! during the handshake and must reject (Err) without panicking. + +use libfuzzer_sys::fuzz_target; + +use dosh::native::{ + NativeClientHello, NativeServerHello, NativeUserAuth, user_auth_transcript, + verify_native_user_auth, verify_server_hello, +}; +use dosh::protocol; + +fuzz_target!(|data: &[u8]| { + // Split the input into three slices and try to decode each into a handshake + // struct. Use a length prefix scheme that is robust to short inputs. + if data.len() < 3 { + return; + } + let n = data.len(); + let a = n / 3; + let b = 2 * n / 3; + let (chunk_client, chunk_server, chunk_auth) = (&data[..a], &data[a..b], &data[b..]); + + let client: Option = protocol::from_body(chunk_client).ok(); + let server: Option = protocol::from_body(chunk_server).ok(); + let auth: Option = protocol::from_body(chunk_auth).ok(); + + if let (Some(client), Some(server)) = (&client, &server) { + // Host signature verification over the transcript must not panic. + let _ = verify_server_hello(client, server); + + if let Some(auth) = &auth { + // Transcript construction and full user-auth verification (signature + // check + authorized-key matching) must not panic on garbage. + let _ = user_auth_transcript(client, server, auth); + let _ = verify_native_user_auth(client, server, auth, &[], None); + } + } +}); diff --git a/fuzz/fuzz_targets/known_hosts.rs b/fuzz/fuzz_targets/known_hosts.rs new file mode 100644 index 0000000..dd7544b --- /dev/null +++ b/fuzz/fuzz_targets/known_hosts.rs @@ -0,0 +1,14 @@ +#![no_main] +//! Fuzz the known_hosts parser and the host-public-key line parser. None may +//! panic on arbitrary input. + +use libfuzzer_sys::fuzz_target; + +use dosh::native::{parse_host_public_key_line, parse_known_hosts}; + +fuzz_target!(|data: &[u8]| { + if let Ok(text) = std::str::from_utf8(data) { + let _ = parse_known_hosts(text); + let _ = parse_host_public_key_line(text); + } +}); diff --git a/fuzz/fuzz_targets/packet_decode.rs b/fuzz/fuzz_targets/packet_decode.rs new file mode 100644 index 0000000..5f40f50 --- /dev/null +++ b/fuzz/fuzz_targets/packet_decode.rs @@ -0,0 +1,23 @@ +#![no_main] +//! Fuzz the wire-packet decoder + decrypt path. +//! +//! Mirrors tests/parser_robustness.rs but driven by libFuzzer so the coverage +//! engine can search for panics in `protocol::decode`, `Header::parse`, and the +//! decode -> decrypt_body pipeline. The objective is: NO PANICS on any input. + +use libfuzzer_sys::fuzz_target; + +use dosh::protocol::{self, CLIENT_TO_SERVER, SERVER_TO_CLIENT}; + +fuzz_target!(|data: &[u8]| { + // Header parsing must never panic. + let _ = protocol::Header::parse(data); + + // Full decode, then attempt decryption with a fixed key in both directions. + // A real attacker controls these bytes; neither path may panic. + if let Ok(packet) = protocol::decode(data) { + let key = [0x42u8; 32]; + let _ = protocol::decrypt_body(&packet, &key, CLIENT_TO_SERVER); + let _ = protocol::decrypt_body(&packet, &key, SERVER_TO_CLIENT); + } +}); diff --git a/tests/hostile_network.rs b/tests/hostile_network.rs new file mode 100644 index 0000000..2d8b6ea --- /dev/null +++ b/tests/hostile_network.rs @@ -0,0 +1,781 @@ +//! Hostile-network integration tests (Track B, spec milestone 5 / §16). +//! +//! These spin up a real `dosh-server` process bound to 127.0.0.1 on a free port +//! in a temp HOME, and drive the wire protocol directly from the test (mirroring +//! the `direct_attach` pattern in tests/integration_smoke.rs). Between the test +//! "client" and the server sits an in-process UDP relay/shim that can drop, +//! reorder, and duplicate datagrams, and can switch the client's source address +//! (by rebinding its upstream socket) mid-session. +//! +//! Assertions, mapped to §16 verification items: +//! * "Stale encrypted packets after reconnect are ignored, not fatal" +//! -> session survives loss/reorder; stale packets after resume don't kill it. +//! * "Replayed transport packets are rejected" / "no double-apply" +//! -> duplicated & replayed Input is applied at most once. +//! * "Client IP/port change preserves the session" +//! -> after the relay rebinds its upstream socket the session keeps working. +//! +//! Determinism: the relay's drop/reorder/dup behavior is driven by a fixed-seed +//! PRNG and by explicit one-shot toggles, never by wall-clock timing, so the +//! tests are reproducible and fast. + +use std::fs; +use std::net::{SocketAddr, UdpSocket}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::thread; +use std::time::{Duration, Instant}; + +use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret}; +use dosh::config::load_server_config; +use dosh::crypto; +use dosh::protocol::{ + self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest, + SERVER_TO_CLIENT, +}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +fn free_udp_port() -> u16 { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket.local_addr().unwrap().port() +} + +fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf { + let config_dir = dir.path().join(".config/dosh"); + fs::create_dir_all(&config_dir).unwrap(); + let config = config_dir.join("server.toml"); + fs::write( + &config, + format!( + r#" +port = {port} +bind = "127.0.0.1" +scrollback = 5000 +auth_ttl_secs = 30 +attach_ticket_ttl_secs = 3600 +allow_attach_tickets = true +client_timeout_secs = 30 +retransmit_window = 256 +default_input_mode = "read-write" +prewarm_sessions = ["default"] +create_on_attach = true +shell = "/bin/sh" +sessions_dir = "{sessions}" +secret_path = "{secret}" +host_key = "{host_key}" +authorized_keys = ["{authorized_keys}"] +"#, + sessions = dir.path().join("sessions").display(), + secret = dir.path().join("secret").display(), + host_key = dir.path().join("host_key").display(), + authorized_keys = dir.path().join("authorized_keys").display(), + ), + ) + .unwrap(); + config +} + +fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { + let server = env!("CARGO_BIN_EXE_dosh-server"); + let child = Command::new(server) + .arg("serve") + .arg("--config") + .arg(config) + .env("HOME", dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + thread::sleep(Duration::from_millis(500)); + child +} + +/// Knobs that control how the relay mishandles datagrams. All knobs default to +/// pass-through. Each is read on every forwarded packet. +struct RelayControls { + /// Probability [0,100] that an upstream (client->server) packet is dropped. + drop_c2s_percent: AtomicU64, + /// Probability [0,100] that a downstream (server->client) packet is dropped. + drop_s2c_percent: AtomicU64, + /// Probability [0,100] that a packet (either direction) is duplicated. + dup_percent: AtomicU64, + /// When set, the relay holds one packet back and releases it after the next + /// one passes, producing a 2-1 reorder. Used as a deterministic toggle. + reorder_next: AtomicBool, + /// Count of upstream packets the relay observed (for assertions). + c2s_count: AtomicU64, + /// Count of downstream packets the relay observed. + s2c_count: AtomicU64, + shutdown: AtomicBool, +} + +impl RelayControls { + fn new() -> Self { + Self { + drop_c2s_percent: AtomicU64::new(0), + drop_s2c_percent: AtomicU64::new(0), + dup_percent: AtomicU64::new(0), + reorder_next: AtomicBool::new(false), + c2s_count: AtomicU64::new(0), + s2c_count: AtomicU64::new(0), + shutdown: AtomicBool::new(false), + } + } +} + +/// Command sent to the relay from the test thread. +enum RelayCmd { + /// Rebind the upstream (toward-server) socket to a fresh local address, + /// simulating a client NAT rebind / IP-port change. Replies the new addr. + RebindUpstream(Sender), +} + +/// A UDP relay sitting between the test client and the real server. +/// +/// `front` is the address the test client sends to. The relay forwards each +/// client datagram to the server over `upstream`, remembering the client's +/// address so server replies can be returned. `upstream` can be replaced on +/// demand to simulate a client source-address change as the server observes it. +struct Relay { + front_addr: SocketAddr, + controls: Arc, + cmd_tx: Sender, + handle: Option>, +} + +impl Relay { + fn spawn(server_port: u16, seed: u64) -> Self { + let front = UdpSocket::bind("127.0.0.1:0").unwrap(); + front + .set_read_timeout(Some(Duration::from_millis(20))) + .unwrap(); + let front_addr = front.local_addr().unwrap(); + let server_addr: SocketAddr = format!("127.0.0.1:{server_port}").parse().unwrap(); + let controls = Arc::new(RelayControls::new()); + let (cmd_tx, cmd_rx) = channel::(); + + let thread_controls = Arc::clone(&controls); + let handle = thread::spawn(move || { + relay_loop(front, server_addr, thread_controls, cmd_rx, seed); + }); + + Self { + front_addr, + controls, + cmd_tx, + handle: Some(handle), + } + } + + fn front_addr(&self) -> SocketAddr { + self.front_addr + } + + fn set_drop_c2s(&self, percent: u64) { + self.controls + .drop_c2s_percent + .store(percent, Ordering::SeqCst); + } + + fn set_drop_s2c(&self, percent: u64) { + self.controls + .drop_s2c_percent + .store(percent, Ordering::SeqCst); + } + + fn set_dup(&self, percent: u64) { + self.controls.dup_percent.store(percent, Ordering::SeqCst); + } + + fn arm_reorder(&self) { + self.controls.reorder_next.store(true, Ordering::SeqCst); + } + + fn clear_impairments(&self) { + self.set_drop_c2s(0); + self.set_drop_s2c(0); + self.set_dup(0); + } + + /// Rebind the relay's upstream socket; the server will see traffic from a + /// new source address afterward. Returns the new upstream local address. + fn rebind_upstream(&self) -> SocketAddr { + let (tx, rx) = channel(); + self.cmd_tx.send(RelayCmd::RebindUpstream(tx)).unwrap(); + rx.recv_timeout(Duration::from_secs(2)) + .expect("relay rebind ack") + } +} + +impl Drop for Relay { + fn drop(&mut self) { + self.controls.shutdown.store(true, Ordering::SeqCst); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +fn relay_loop( + front: UdpSocket, + server_addr: SocketAddr, + controls: Arc, + cmd_rx: Receiver, + seed: u64, +) { + let mut upstream = new_upstream(); + let mut client_addr: Option = None; + let mut rng = StdRng::seed_from_u64(seed); + let mut held: Option<(Vec, bool)> = None; // (packet, is_c2s) held for reorder + let mut buf = [0u8; 65535]; + + loop { + if controls.shutdown.load(Ordering::SeqCst) { + return; + } + + // Process any pending control commands. + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + RelayCmd::RebindUpstream(reply) => { + upstream = new_upstream(); + let _ = reply.send(upstream.local_addr().unwrap()); + } + } + } + + // Client -> server. + match front.recv_from(&mut buf) { + Ok((n, src)) => { + client_addr = Some(src); + controls.c2s_count.fetch_add(1, Ordering::SeqCst); + let packet = buf[..n].to_vec(); + let drop_pct = controls.drop_c2s_percent.load(Ordering::SeqCst); + if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct { + forward_with_effects( + &upstream, + server_addr, + packet, + true, + &controls, + &mut rng, + &mut held, + ); + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(_) => return, + } + + // Server -> client. + match upstream.recv_from(&mut buf) { + Ok((n, _src)) => { + controls.s2c_count.fetch_add(1, Ordering::SeqCst); + if let Some(dst) = client_addr { + let packet = buf[..n].to_vec(); + let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst); + if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct { + forward_with_effects( + &front, dst, packet, false, &controls, &mut rng, &mut held, + ); + } + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(_) => return, + } + } +} + +fn new_upstream() -> UdpSocket { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(20))) + .unwrap(); + socket +} + +/// Forward a packet to `dst` over `out`, applying duplication and reorder. +fn forward_with_effects( + out: &UdpSocket, + dst: SocketAddr, + packet: Vec, + is_c2s: bool, + controls: &RelayControls, + rng: &mut StdRng, + held: &mut Option<(Vec, bool)>, +) { + // Reorder: if armed, hold this packet and release the previously held one + // afterward (so two consecutive packets swap order). + if controls.reorder_next.swap(false, Ordering::SeqCst) { + if let Some((prev, _)) = held.take() { + let _ = out.send_to(&packet, dst); + let _ = out.send_to(&prev, dst); + return; + } + *held = Some((packet, is_c2s)); + return; + } + if let Some((prev, _)) = held.take() { + let _ = out.send_to(&prev, dst); + } + + let _ = out.send_to(&packet, dst); + + let dup_pct = controls.dup_percent.load(Ordering::SeqCst); + if dup_pct > 0 && rng.gen_range(0..100) < dup_pct { + let _ = out.send_to(&packet, dst); + } +} + +/// Build a bootstrap and attach through the relay, returning the client socket, +/// the bootstrap (for the session key), and the AttachOk. +fn attach_through_relay( + config: &std::path::Path, + relay: &Relay, +) -> (UdpSocket, BootstrapResponse, AttachOk) { + let config = load_server_config(Some(config.to_path_buf())).unwrap(); + let secret = load_or_create_server_secret(&config).unwrap(); + let bootstrap = build_bootstrap( + &config, + &secret, + "tester".to_string(), + "default".to_string(), + "read-write".to_string(), + (80, 24), + crypto::random_12(), + "127.0.0.1".to_string(), + ) + .unwrap(); + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let req = protocol::BootstrapAttachRequest { + bootstrap: bootstrap.clone(), + cols: 80, + rows: 24, + requested_env: Vec::new(), + }; + let packet = protocol::encode_plain( + PacketKind::BootstrapAttachRequest, + [0u8; 16], + 1, + 0, + &protocol::to_body(&req).unwrap(), + ) + .unwrap(); + + // Retry the attach request a few times in case the relay drops it. + let deadline = Instant::now() + Duration::from_secs(5); + loop { + socket.send_to(&packet, relay.front_addr()).unwrap(); + let mut buf = [0u8; 65535]; + match socket.recv_from(&mut buf) { + Ok((n, _)) => { + if let Ok(decoded) = protocol::decode(&buf[..n]) { + if decoded.header.kind == PacketKind::AttachOk { + let plain = protocol::decrypt_body( + &decoded, + &bootstrap.session_key, + SERVER_TO_CLIENT, + ) + .unwrap(); + let ok: AttachOk = protocol::from_body(&plain).unwrap(); + return (socket, bootstrap, ok); + } + } + } + Err(_) => {} + } + if Instant::now() > deadline { + panic!("attach through relay timed out"); + } + } +} + +fn send_input( + socket: &UdpSocket, + relay: &Relay, + client_id: [u8; 16], + seq: u64, + ack: u64, + key: &[u8; 32], + text: &[u8], +) { + let input = Input { + bytes: text.to_vec(), + }; + let packet = protocol::encode_encrypted( + PacketKind::Input, + client_id, + seq, + ack, + key, + CLIENT_TO_SERVER, + &protocol::to_body(&input).unwrap(), + ) + .unwrap(); + socket.send_to(&packet, relay.front_addr()).unwrap(); +} + +fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) { + socket.send_to(packet, relay.front_addr()).unwrap(); +} + +fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> { + let mut buf = [0u8; 65535]; + let (n, _) = socket.recv_from(&mut buf).ok()?; + let packet = protocol::decode(&buf[..n]).ok()?; + match packet.header.kind { + PacketKind::Frame | PacketKind::ResumeOk => { + let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?; + let frame: Frame = protocol::from_body(&plain).ok()?; + Some((packet.header, frame)) + } + _ => None, + } +} + +/// Collect terminal output text for up to `millis`, returning all decoded frame +/// bytes concatenated. +fn collect_text(socket: &UdpSocket, key: &[u8; 32], millis: u64) -> String { + let prev = socket.read_timeout().unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let deadline = Instant::now() + Duration::from_millis(millis); + let mut text = String::new(); + while Instant::now() < deadline { + if let Some((_h, frame)) = recv_frame(socket, key) { + text.push_str(&String::from_utf8_lossy(&frame.bytes)); + } + } + socket.set_read_timeout(prev).unwrap(); + text +} + +/// Wait until terminal output containing `needle` is observed, retrying for up +/// to `millis`. Returns true if seen. +fn wait_for_text(socket: &UdpSocket, key: &[u8; 32], needle: &str, millis: u64) -> bool { + let deadline = Instant::now() + Duration::from_millis(millis); + let mut acc = String::new(); + while Instant::now() < deadline { + acc.push_str(&collect_text(socket, key, 200)); + if acc.contains(needle) { + return true; + } + } + acc.contains(needle) +} + +#[test] +fn session_survives_packet_loss_and_reorder() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let relay = Relay::spawn(port, 0x105_5u64 ^ 0x1111); + + let (socket, bootstrap, ok) = attach_through_relay(&config, &relay); + + // Introduce 40% loss in both directions and frequent duplication, plus + // reorder on the input flight. The server retransmits unacked frames and + // the client retransmits input, so the command must still land. + relay.set_drop_c2s(40); + relay.set_drop_s2c(40); + relay.set_dup(30); + + let mut seq = 2u64; + let mut seen = false; + // Send the same logical command several times with monotonically rising + // sequence numbers (as a real client retransmitting would), interleaving a + // reorder toggle, until the output is observed despite the lossy link. + for attempt in 0..20 { + if attempt % 3 == 0 { + relay.arm_reorder(); + } + send_input( + &socket, + &relay, + ok.client_id, + seq, + 0, + &bootstrap.session_key, + b"printf DOSH_LOSSY_OK\\n\n", + ); + seq += 1; + if wait_for_text(&socket, &bootstrap.session_key, "DOSH_LOSSY_OK", 400) { + seen = true; + break; + } + } + + relay.clear_impairments(); + drop(relay); + let _ = server.kill(); + let _ = server.wait(); + + assert!( + seen, + "terminal output never arrived across a lossy/reordering link" + ); +} + +#[test] +fn duplicated_and_replayed_input_is_applied_at_most_once() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let relay = Relay::spawn(port, 0xD0D0u64); + + let (socket, bootstrap, ok) = attach_through_relay(&config, &relay); + + // Append a fixed token to a file once per *distinct* delivered input. We use + // `>>` so every time the server's PTY actually executes the command, a new + // line is appended. Replay protection must ensure the duplicate/replayed + // packet at the same sequence number is NOT re-applied. + let marker = dir.path().join("dup_marker"); + let cmd = format!("printf x >> {}\n", marker.display()); + + // Build one encrypted Input packet at a fixed sequence and send it many + // times verbatim (a true replay: identical bytes, identical seq/nonce). + let input = Input { + bytes: cmd.into_bytes(), + }; + let replayed = protocol::encode_encrypted( + PacketKind::Input, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&input).unwrap(), + ) + .unwrap(); + + // Also have the relay duplicate everything, to stack duplication on top of + // our explicit replays. + relay.set_dup(100); + for _ in 0..12 { + send_raw(&socket, &relay, &replayed); + thread::sleep(Duration::from_millis(40)); + } + relay.set_dup(0); + + // Give the PTY time to run and flush. + thread::sleep(Duration::from_millis(800)); + + // Drive a fence command so we know the PTY has processed at least up to here + // before we read the marker file. + send_input( + &socket, + &relay, + ok.client_id, + 3, + 0, + &bootstrap.session_key, + b"printf DUP_FENCE\\n\n", + ); + let _ = wait_for_text(&socket, &bootstrap.session_key, "DUP_FENCE", 2000); + thread::sleep(Duration::from_millis(300)); + + let count = fs::read(&marker).map(|b| b.len()).unwrap_or(0); + + drop(relay); + let _ = server.kill(); + let _ = server.wait(); + + // The replayed/duplicated identical packet must apply at most once. If + // replay protection were broken we would see many 'x' bytes. + assert!( + count <= 1, + "replayed/duplicated input was applied {count} times (expected at most 1)" + ); +} + +#[test] +fn stale_packets_after_resume_are_ignored_not_fatal() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let relay = Relay::spawn(port, 0x57A1u64); + + let (old_socket, bootstrap, ok) = attach_through_relay(&config, &relay); + + // Send an initial command on the original socket and confirm it lands. + send_input( + &old_socket, + &relay, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + b"printf DOSH_STALE_BEFORE\\n\n", + ); + assert!( + wait_for_text( + &old_socket, + &bootstrap.session_key, + "DOSH_STALE_BEFORE", + 3000 + ), + "initial command did not land before resume" + ); + + // Simulate a reconnect from a new socket via a ResumeRequest (roaming), + // mirroring tests/integration_smoke.rs::resume_updates_udp_endpoint. + let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + new_socket + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let resume = ResumeRequest { + session: "default".to_string(), + last_rendered_seq: ok.initial_seq, + cols: 80, + rows: 24, + }; + let resume_packet = protocol::encode_encrypted( + PacketKind::ResumeRequest, + ok.client_id, + 100, + 0, + &bootstrap.session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&resume).unwrap(), + ) + .unwrap(); + let mut resumed_seq = None; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + new_socket + .send_to(&resume_packet, relay.front_addr()) + .unwrap(); + if let Some((_h, frame)) = recv_frame(&new_socket, &bootstrap.session_key) { + if frame.snapshot { + resumed_seq = Some(frame.output_seq); + break; + } + } + } + let resumed_seq = resumed_seq.expect("resume snapshot never arrived"); + + // Now replay a STALE packet from the OLD socket with a low sequence number + // (already-seen / out of the replay window). This must NOT terminate the + // session. + let stale = protocol::encode_encrypted( + PacketKind::Input, + ok.client_id, + 2, // old, already-consumed sequence + 0, + &bootstrap.session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&Input { + bytes: b"printf DOSH_STALE_REPLAY\\n\n".to_vec(), + }) + .unwrap(), + ) + .unwrap(); + for _ in 0..5 { + old_socket.send_to(&stale, relay.front_addr()).unwrap(); + thread::sleep(Duration::from_millis(30)); + } + + // The session must remain alive on the resumed socket: a fresh command with + // a higher sequence still produces output. + send_input( + &new_socket, + &relay, + ok.client_id, + 101, + resumed_seq, + &bootstrap.session_key, + b"printf DOSH_STALE_AFTER\\n\n", + ); + let alive = wait_for_text( + &new_socket, + &bootstrap.session_key, + "DOSH_STALE_AFTER", + 3000, + ); + + drop(relay); + let _ = server.kill(); + let _ = server.wait(); + + assert!( + alive, + "session was killed by stale packets after reconnect (should be ignored, not fatal)" + ); +} + +#[test] +fn client_source_address_change_preserves_session() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let relay = Relay::spawn(port, 0xADD12u64); + + let (socket, bootstrap, ok) = attach_through_relay(&config, &relay); + + // Confirm the session works before the address change. + send_input( + &socket, + &relay, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + b"printf DOSH_ADDR_BEFORE\\n\n", + ); + assert!( + wait_for_text(&socket, &bootstrap.session_key, "DOSH_ADDR_BEFORE", 3000), + "command before source-address change did not land" + ); + + // Switch the client's source address as the server sees it by rebinding the + // relay's upstream socket (spec §11: "Connection migration must be accepted + // after any valid encrypted packet from a new source address"). + let new_addr = relay.rebind_upstream(); + assert_ne!(new_addr.port(), 0); + + // The next valid encrypted packet now arrives from a new source address. + // The session must keep working without a re-handshake. + let mut migrated = false; + let mut seq = 3u64; + for _ in 0..12 { + send_input( + &socket, + &relay, + ok.client_id, + seq, + 0, + &bootstrap.session_key, + b"printf DOSH_ADDR_AFTER\\n\n", + ); + seq += 1; + if wait_for_text(&socket, &bootstrap.session_key, "DOSH_ADDR_AFTER", 500) { + migrated = true; + break; + } + } + + drop(relay); + let _ = server.kill(); + let _ = server.wait(); + + assert!( + migrated, + "session did not survive a client source-address change (connection migration)" + ); +} diff --git a/tests/parser_robustness.rs b/tests/parser_robustness.rs new file mode 100644 index 0000000..6380b32 --- /dev/null +++ b/tests/parser_robustness.rs @@ -0,0 +1,415 @@ +//! Parser robustness tests (Track B, spec milestone 5 / §16 "Fuzz packet parsing"). +//! +//! These throw arbitrary/garbage bytes at every reachable public parser in the +//! `dosh` library and assert that NONE of them panic. A parser is allowed to +//! return `Ok` (if the bytes happened to be valid) or `Err`, but a panic on +//! untrusted input is a denial-of-service / robustness bug against a hostile +//! network attacker (threat model §5: "Active network attacker that can spoof +//! ... or modify packets"). +//! +//! Determinism: a fixed-seed PRNG (`rand::rngs::StdRng`) is used so failures are +//! reproducible. No external dependencies beyond what is already in Cargo.toml. + +use std::panic::{self, AssertUnwindSafe}; + +use dosh::auth::{ + AttachTicketPlain, BootstrapResponse, SealedAttachTicket, decode_bootstrap, open_attach_ticket, + verify_attach_ticket, +}; +use dosh::native::{ + AuthorizedKey, HostPublicKey, KnownHost, NativeAuthOk, NativeClientHello, NativeServerHello, + NativeUserAuth, parse_authorized_keys, parse_host_public_key_line, parse_known_hosts, + parse_ssh_ed25519_public_blob, verify_known_host, +}; +use dosh::protocol::{ + self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Header, Input, + NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, + NativeUserAuthBody, Packet, Resize, ResumeRequest, StreamClose, StreamData, StreamEof, + StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody, + TicketAttachEnvelope, TicketAttachOkEnvelope, +}; +use rand::rngs::StdRng; +use rand::{Rng, RngCore, SeedableRng}; + +const ITERATIONS: usize = 4000; + +/// Run `f` and convert a panic into a test failure with a descriptive message. +fn no_panic(label: &str, input: &[u8], f: F) { + let result = panic::catch_unwind(AssertUnwindSafe(f)); + assert!( + result.is_ok(), + "parser `{label}` PANICKED on input ({} bytes): {:02x?}", + input.len(), + input, + ); +} + +/// Generate a variety of "interesting" byte buffers for a given iteration. +fn fuzz_bytes(rng: &mut StdRng) -> Vec { + let strategy = rng.gen_range(0..7u8); + match strategy { + 0 => { + let len = rng.gen_range(0..1200); + let mut buf = vec![0u8; len]; + rng.fill_bytes(&mut buf); + buf + } + 1 => { + let len = rng.gen_range(0..16); + let mut buf = vec![0u8; len]; + rng.fill_bytes(&mut buf); + buf + } + 2 => { + let len = rng.gen_range(0..(protocol::HEADER_LEN + 64)); + let mut buf = vec![0u8; len]; + rng.fill_bytes(&mut buf); + buf + } + 3 => vec![0u8; rng.gen_range(0..256)], + 4 => vec![0xffu8; rng.gen_range(0..256)], + 5 => { + // A valid-magic prefix followed by garbage to drive deeper paths. + let mut buf = Vec::new(); + buf.extend_from_slice(protocol::MAGIC); + buf.push(protocol::VERSION); + let extra = rng.gen_range(0..256); + let mut tail = vec![0u8; extra]; + rng.fill_bytes(&mut tail); + buf.extend_from_slice(&tail); + buf + } + _ => { + // Large length prefixes to provoke huge allocations / overflow in + // length fields (a classic deserialization hazard). + let mut buf = Vec::new(); + buf.extend_from_slice(&u64::MAX.to_le_bytes()); + let extra = rng.gen_range(0..64); + let mut tail = vec![0u8; extra]; + rng.fill_bytes(&mut tail); + buf.extend_from_slice(&tail); + buf + } + } +} + +/// Generate a possibly-valid UTF-8 string from random bytes (for text parsers). +fn fuzz_text(rng: &mut StdRng) -> String { + let len = rng.gen_range(0..256); + let mut s = String::new(); + for _ in 0..len { + let pick = rng.gen_range(0..10u8); + let ch = match pick { + 0 => ' ', + 1 => '\n', + 2 => '\t', + 3 => '=', + 4 => ',', + 5 => '"', + 6 => '/', + 7 => rng.gen_range(b'a'..=b'z') as char, + 8 => rng.gen_range(b'0'..=b'9') as char, + _ => char::from_u32(rng.gen_range(0..0x110000)).unwrap_or('?'), + }; + s.push(ch); + } + s +} + +#[test] +fn protocol_packet_decode_never_panics() { + let mut rng = StdRng::seed_from_u64(0xD05Au64); + for _ in 0..ITERATIONS { + let input = fuzz_bytes(&mut rng); + no_panic("protocol::decode", &input, || { + let _ = protocol::decode(&input); + }); + no_panic("Header::parse", &input, || { + let _ = Header::parse(&input); + }); + } +} + +/// `from_body` deserializes a bincode body into each protocol/native struct. +/// On the wire this runs on attacker-controlled bytes, so it must never panic. +#[test] +fn protocol_from_body_never_panics() { + let mut rng = StdRng::seed_from_u64(0xBEEFu64); + + macro_rules! body_target { + ($input:expr, $ty:ty) => {{ + let input = $input; + no_panic(concat!("from_body::<", stringify!($ty), ">"), input, || { + let _ = protocol::from_body::<$ty>(input); + }); + }}; + } + + for _ in 0..ITERATIONS { + let input = fuzz_bytes(&mut rng); + let input = input.as_slice(); + + // protocol.rs structs + body_target!(input, BootstrapAttachRequest); + body_target!(input, TicketAttachEnvelope); + body_target!(input, TicketAttachBody); + body_target!(input, TicketAttachOkEnvelope); + body_target!(input, AttachOk); + body_target!(input, AttachReject); + body_target!(input, ResumeRequest); + body_target!(input, Input); + body_target!(input, Resize); + body_target!(input, Frame); + body_target!(input, StreamOpen); + body_target!(input, StreamOpenOk); + body_target!(input, StreamOpenReject); + body_target!(input, StreamData); + body_target!(input, StreamWindowAdjust); + body_target!(input, StreamEof); + body_target!(input, StreamClose); + + // native handshake wrapper bodies + body_target!(input, NativeClientHelloBody); + body_target!(input, NativeServerHelloBody); + body_target!(input, NativeUserAuthBody); + body_target!(input, NativeAuthOkBody); + body_target!(input, NativeAuthCheckOkBody); + + // bare native handshake structs + body_target!(input, NativeClientHello); + body_target!(input, NativeServerHello); + body_target!(input, NativeUserAuth); + body_target!(input, NativeAuthOk); + body_target!(input, HostPublicKey); + + // auth.rs structs (deserialized from untrusted material too) + body_target!(input, BootstrapResponse); + body_target!(input, SealedAttachTicket); + body_target!(input, AttachTicketPlain); + } +} + +/// Full decode -> decrypt_body pipeline on garbage. decrypt should Err (not +/// panic) on bad ciphertext / wrong key id / truncated body. +#[test] +fn protocol_decode_then_decrypt_never_panics() { + let mut rng = StdRng::seed_from_u64(0x1234_5678u64); + let key = [7u8; 32]; + for _ in 0..ITERATIONS { + let input = fuzz_bytes(&mut rng); + no_panic("decode+decrypt_body", &input, || { + if let Ok(packet) = protocol::decode(&input) { + let _ = protocol::decrypt_body(&packet, &key, protocol::CLIENT_TO_SERVER); + let _ = protocol::decrypt_body(&packet, &key, protocol::SERVER_TO_CLIENT); + } + }); + } +} + +/// Mutate a single byte of a valid encrypted packet; decode and decrypt must +/// not panic, and decryption of the mutated packet must fail (no double-apply). +#[test] +fn protocol_bit_flips_on_valid_packet_never_panic() { + let mut rng = StdRng::seed_from_u64(0x900Du64); + let key = [9u8; 32]; + let conn_id = [3u8; 16]; + for _ in 0..1000 { + let mut plaintext = vec![0u8; rng.gen_range(0..200)]; + rng.fill_bytes(&mut plaintext); + let seq = rng.gen_range(1..u64::MAX); + let Ok(mut packet) = protocol::encode_encrypted( + protocol::PacketKind::Input, + conn_id, + seq, + 0, + &key, + protocol::CLIENT_TO_SERVER, + &plaintext, + ) else { + continue; + }; + if packet.is_empty() { + continue; + } + let idx = rng.gen_range(0..packet.len()); + packet[idx] ^= 1 << rng.gen_range(0..8); + no_panic("flip+decode+decrypt", &packet, || { + if let Ok(decoded) = protocol::decode(&packet) { + let _ = protocol::decrypt_body(&decoded, &key, protocol::CLIENT_TO_SERVER); + } + }); + } +} + +#[test] +fn ssh_ed25519_blob_parser_never_panics() { + let mut rng = StdRng::seed_from_u64(0x5511u64); + for _ in 0..ITERATIONS { + let input = fuzz_bytes(&mut rng); + no_panic("parse_ssh_ed25519_public_blob", &input, || { + let _ = parse_ssh_ed25519_public_blob(&input); + }); + } + // Targeted: length prefixes that lie about the body length. + for bad_len in [0u32, 1, 31, 32, 33, u32::MAX, u32::MAX - 1] { + let mut buf = Vec::new(); + buf.extend_from_slice(&bad_len.to_be_bytes()); + buf.extend_from_slice(b"ssh-ed25519"); + buf.extend_from_slice(&32u32.to_be_bytes()); + buf.extend_from_slice(&[0u8; 16]); + no_panic("parse_ssh_ed25519_public_blob:lying-len", &buf, || { + let _ = parse_ssh_ed25519_public_blob(&buf); + }); + } +} + +#[test] +fn authorized_keys_parser_never_panics() { + let mut rng = StdRng::seed_from_u64(0xA011u64); + for _ in 0..ITERATIONS { + let text = fuzz_text(&mut rng); + no_panic("parse_authorized_keys", text.as_bytes(), || { + let _ = parse_authorized_keys(&text); + }); + } + let crafted = [ + "ssh-ed25519", + "ssh-ed25519 ", + "ssh-ed25519 not-base64!!!", + "from= ssh-ed25519 AAAA", + "from=\"unterminated ssh-ed25519 AAAA", + "command=\"x\\\" ssh-ed25519 AAAA", + "permitopen=,,, ssh-ed25519 AAAA", + "restrict,no-port-forwarding,from=\"127.0.0.1\" ssh-ed25519 AAAA comment", + "ssh-rsa AAAA", + "\u{0}\u{0}\u{0} ssh-ed25519 AAAA", + ]; + for line in crafted { + no_panic("parse_authorized_keys:crafted", line.as_bytes(), || { + let _ = parse_authorized_keys(line); + }); + } +} + +#[test] +fn known_hosts_parser_never_panics() { + let mut rng = StdRng::seed_from_u64(0xC051u64); + for _ in 0..ITERATIONS { + let text = fuzz_text(&mut rng); + no_panic("parse_known_hosts", text.as_bytes(), || { + let _ = parse_known_hosts(&text); + }); + } + let crafted = [ + "host", + "host dosh-ed25519", + "host dosh-ed25519 not-base64!!!", + "host wrong-algo AAAA", + "host dosh-ed25519 AAAA first-seen=notnum source=tofu", + "host dosh-ed25519 AAAA first-seen= source=", + "* dosh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ]; + for line in crafted { + no_panic("parse_known_hosts:crafted", line.as_bytes(), || { + let _ = parse_known_hosts(line); + }); + } +} + +#[test] +fn host_public_key_line_parser_never_panics() { + let mut rng = StdRng::seed_from_u64(0x4002u64); + for _ in 0..ITERATIONS { + let text = fuzz_text(&mut rng); + no_panic("parse_host_public_key_line", text.as_bytes(), || { + let _ = parse_host_public_key_line(&text); + }); + } +} + +#[test] +fn decode_bootstrap_never_panics() { + let mut rng = StdRng::seed_from_u64(0xB007u64); + for _ in 0..ITERATIONS { + let text = fuzz_text(&mut rng); + no_panic("decode_bootstrap", text.as_bytes(), || { + let _ = decode_bootstrap(&text); + }); + // Also feed base64-shaped random for the decode path proper. + let raw = fuzz_bytes(&mut rng); + use base64::Engine; + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&raw); + no_panic("decode_bootstrap:b64", b64.as_bytes(), || { + let _ = decode_bootstrap(&b64); + }); + } +} + +#[test] +fn attach_ticket_open_and_verify_never_panic() { + let mut rng = StdRng::seed_from_u64(0x7CE7u64); + let secret = [42u8; 32]; + let psk = [11u8; 32]; + for _ in 0..ITERATIONS { + let input = fuzz_bytes(&mut rng); + no_panic("open_attach_ticket", &input, || { + let _ = open_attach_ticket(&secret, &input); + }); + no_panic("verify_attach_ticket", &input, || { + let _ = verify_attach_ticket(&secret, &input, &psk, "default", "read-write"); + }); + } +} + +/// Throw garbage at the known-host verifier (file parse + host key compare). +#[test] +fn verify_known_host_with_garbage_keys_never_panics() { + let mut rng = StdRng::seed_from_u64(0x9090u64); + let dir = tempfile::tempdir().unwrap(); + for _ in 0..500 { + let text = fuzz_text(&mut rng); + let path = dir.path().join("known_hosts"); + std::fs::write(&path, &text).unwrap(); + let mut key_bytes = [0u8; 32]; + rng.fill_bytes(&mut key_bytes); + let host = HostPublicKey { + algorithm: "dosh-ed25519".to_string(), + key: key_bytes, + }; + let host_name = fuzz_text(&mut rng); + no_panic("verify_known_host", text.as_bytes(), || { + let _ = verify_known_host(&path, &host_name, &host); + }); + } +} + +/// Regression guard: valid inputs still parse, so the fuzz harness isn't +/// accidentally exercising a build where every path simply Errs. +#[test] +fn valid_inputs_still_parse() { + let key = [5u8; 32]; + let blob = dosh::native::ssh_ed25519_public_blob(&key); + assert_eq!(parse_ssh_ed25519_public_blob(&blob).unwrap(), key); + + let session_key = [1u8; 32]; + let packet = protocol::encode_encrypted( + protocol::PacketKind::Input, + [2u8; 16], + 1, + 0, + &session_key, + protocol::CLIENT_TO_SERVER, + b"hello", + ) + .unwrap(); + let decoded: Packet = protocol::decode(&packet).unwrap(); + let plain = protocol::decrypt_body(&decoded, &session_key, protocol::CLIENT_TO_SERVER).unwrap(); + assert_eq!(plain, b"hello"); + + assert!(parse_authorized_keys("").unwrap().is_empty()); + assert!(parse_known_hosts("# just a comment\n").unwrap().is_empty()); + + // Reference types only otherwise used in macro expansions / signatures. + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); +}