//! 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::(); }