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>
This commit is contained in:
DuProcess
2026-06-14 10:31:47 -04:00
co-authored by Claude Opus 4.8
parent 6c14d669b8
commit 9b0f09a8a8
12 changed files with 1537 additions and 0 deletions
+20
View File
@@ -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);
}
});
+18
View File
@@ -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);
}
});
+62
View File
@@ -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);
});
+44
View File
@@ -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<NativeClientHello> = protocol::from_body(chunk_client).ok();
let server: Option<NativeServerHello> = protocol::from_body(chunk_server).ok();
let auth: Option<NativeUserAuth> = 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);
}
}
});
+14
View File
@@ -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);
}
});
+23
View File
@@ -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);
}
});