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>
416 lines
14 KiB
Rust
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>();
|
|
}
|