Files
dosh/tests/parser_robustness.rs
DuProcess 9b0f09a8a8 Add hostile-network tests, parser fuzzing, and CI fuzz job
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 <noreply@anthropic.com>
2026-06-14 10:31:47 -04:00

416 lines
14 KiB
Rust

//! 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<F: FnOnce()>(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<u8> {
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::<AuthorizedKey>();
let _ = std::mem::size_of::<KnownHost>();
}