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:
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
/corpus
|
||||
/artifacts
|
||||
/coverage
|
||||
Cargo.lock
|
||||
@@ -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
|
||||
@@ -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::<T>` 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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user