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:
co-authored by
Claude Opus 4.8
parent
6c14d669b8
commit
9b0f09a8a8
@@ -19,6 +19,38 @@ jobs:
|
|||||||
- name: Docker SSH benchmark gate
|
- name: Docker SSH benchmark gate
|
||||||
run: sh scripts/ci-docker-ssh-bench.sh
|
run: sh scripts/ci-docker-ssh-bench.sh
|
||||||
|
|
||||||
|
fuzz-smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install nightly toolchain
|
||||||
|
id: nightly
|
||||||
|
continue-on-error: true
|
||||||
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
|
- name: Install cargo-fuzz
|
||||||
|
id: install
|
||||||
|
if: steps.nightly.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
run: cargo install cargo-fuzz --locked
|
||||||
|
- name: Run fuzz targets briefly
|
||||||
|
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
for target in \
|
||||||
|
packet_decode \
|
||||||
|
from_body \
|
||||||
|
authorized_keys \
|
||||||
|
known_hosts \
|
||||||
|
handshake_structs \
|
||||||
|
attach_ticket; do
|
||||||
|
echo "== fuzzing $target =="
|
||||||
|
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
|
||||||
|
-max_total_time=20 -rss_limit_mb=4096
|
||||||
|
done
|
||||||
|
- name: Note when fuzzing was skipped
|
||||||
|
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
|
||||||
|
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
|
||||||
|
|
||||||
remote-bench:
|
remote-bench:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
//! Hostile-network integration tests (Track B, spec milestone 5 / §16).
|
||||||
|
//!
|
||||||
|
//! These spin up a real `dosh-server` process bound to 127.0.0.1 on a free port
|
||||||
|
//! in a temp HOME, and drive the wire protocol directly from the test (mirroring
|
||||||
|
//! the `direct_attach` pattern in tests/integration_smoke.rs). Between the test
|
||||||
|
//! "client" and the server sits an in-process UDP relay/shim that can drop,
|
||||||
|
//! reorder, and duplicate datagrams, and can switch the client's source address
|
||||||
|
//! (by rebinding its upstream socket) mid-session.
|
||||||
|
//!
|
||||||
|
//! Assertions, mapped to §16 verification items:
|
||||||
|
//! * "Stale encrypted packets after reconnect are ignored, not fatal"
|
||||||
|
//! -> session survives loss/reorder; stale packets after resume don't kill it.
|
||||||
|
//! * "Replayed transport packets are rejected" / "no double-apply"
|
||||||
|
//! -> duplicated & replayed Input is applied at most once.
|
||||||
|
//! * "Client IP/port change preserves the session"
|
||||||
|
//! -> after the relay rebinds its upstream socket the session keeps working.
|
||||||
|
//!
|
||||||
|
//! Determinism: the relay's drop/reorder/dup behavior is driven by a fixed-seed
|
||||||
|
//! PRNG and by explicit one-shot toggles, never by wall-clock timing, so the
|
||||||
|
//! tests are reproducible and fast.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::net::{SocketAddr, UdpSocket};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
|
||||||
|
use dosh::config::load_server_config;
|
||||||
|
use dosh::crypto;
|
||||||
|
use dosh::protocol::{
|
||||||
|
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest,
|
||||||
|
SERVER_TO_CLIENT,
|
||||||
|
};
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
|
||||||
|
fn free_udp_port() -> u16 {
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
socket.local_addr().unwrap().port()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf {
|
||||||
|
let config_dir = dir.path().join(".config/dosh");
|
||||||
|
fs::create_dir_all(&config_dir).unwrap();
|
||||||
|
let config = config_dir.join("server.toml");
|
||||||
|
fs::write(
|
||||||
|
&config,
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
port = {port}
|
||||||
|
bind = "127.0.0.1"
|
||||||
|
scrollback = 5000
|
||||||
|
auth_ttl_secs = 30
|
||||||
|
attach_ticket_ttl_secs = 3600
|
||||||
|
allow_attach_tickets = true
|
||||||
|
client_timeout_secs = 30
|
||||||
|
retransmit_window = 256
|
||||||
|
default_input_mode = "read-write"
|
||||||
|
prewarm_sessions = ["default"]
|
||||||
|
create_on_attach = true
|
||||||
|
shell = "/bin/sh"
|
||||||
|
sessions_dir = "{sessions}"
|
||||||
|
secret_path = "{secret}"
|
||||||
|
host_key = "{host_key}"
|
||||||
|
authorized_keys = ["{authorized_keys}"]
|
||||||
|
"#,
|
||||||
|
sessions = dir.path().join("sessions").display(),
|
||||||
|
secret = dir.path().join("secret").display(),
|
||||||
|
host_key = dir.path().join("host_key").display(),
|
||||||
|
authorized_keys = dir.path().join("authorized_keys").display(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
|
||||||
|
let server = env!("CARGO_BIN_EXE_dosh-server");
|
||||||
|
let child = Command::new(server)
|
||||||
|
.arg("serve")
|
||||||
|
.arg("--config")
|
||||||
|
.arg(config)
|
||||||
|
.env("HOME", dir.path())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
child
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knobs that control how the relay mishandles datagrams. All knobs default to
|
||||||
|
/// pass-through. Each is read on every forwarded packet.
|
||||||
|
struct RelayControls {
|
||||||
|
/// Probability [0,100] that an upstream (client->server) packet is dropped.
|
||||||
|
drop_c2s_percent: AtomicU64,
|
||||||
|
/// Probability [0,100] that a downstream (server->client) packet is dropped.
|
||||||
|
drop_s2c_percent: AtomicU64,
|
||||||
|
/// Probability [0,100] that a packet (either direction) is duplicated.
|
||||||
|
dup_percent: AtomicU64,
|
||||||
|
/// When set, the relay holds one packet back and releases it after the next
|
||||||
|
/// one passes, producing a 2-1 reorder. Used as a deterministic toggle.
|
||||||
|
reorder_next: AtomicBool,
|
||||||
|
/// Count of upstream packets the relay observed (for assertions).
|
||||||
|
c2s_count: AtomicU64,
|
||||||
|
/// Count of downstream packets the relay observed.
|
||||||
|
s2c_count: AtomicU64,
|
||||||
|
shutdown: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayControls {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
drop_c2s_percent: AtomicU64::new(0),
|
||||||
|
drop_s2c_percent: AtomicU64::new(0),
|
||||||
|
dup_percent: AtomicU64::new(0),
|
||||||
|
reorder_next: AtomicBool::new(false),
|
||||||
|
c2s_count: AtomicU64::new(0),
|
||||||
|
s2c_count: AtomicU64::new(0),
|
||||||
|
shutdown: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Command sent to the relay from the test thread.
|
||||||
|
enum RelayCmd {
|
||||||
|
/// Rebind the upstream (toward-server) socket to a fresh local address,
|
||||||
|
/// simulating a client NAT rebind / IP-port change. Replies the new addr.
|
||||||
|
RebindUpstream(Sender<SocketAddr>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A UDP relay sitting between the test client and the real server.
|
||||||
|
///
|
||||||
|
/// `front` is the address the test client sends to. The relay forwards each
|
||||||
|
/// client datagram to the server over `upstream`, remembering the client's
|
||||||
|
/// address so server replies can be returned. `upstream` can be replaced on
|
||||||
|
/// demand to simulate a client source-address change as the server observes it.
|
||||||
|
struct Relay {
|
||||||
|
front_addr: SocketAddr,
|
||||||
|
controls: Arc<RelayControls>,
|
||||||
|
cmd_tx: Sender<RelayCmd>,
|
||||||
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Relay {
|
||||||
|
fn spawn(server_port: u16, seed: u64) -> Self {
|
||||||
|
let front = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
front
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(20)))
|
||||||
|
.unwrap();
|
||||||
|
let front_addr = front.local_addr().unwrap();
|
||||||
|
let server_addr: SocketAddr = format!("127.0.0.1:{server_port}").parse().unwrap();
|
||||||
|
let controls = Arc::new(RelayControls::new());
|
||||||
|
let (cmd_tx, cmd_rx) = channel::<RelayCmd>();
|
||||||
|
|
||||||
|
let thread_controls = Arc::clone(&controls);
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
relay_loop(front, server_addr, thread_controls, cmd_rx, seed);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
front_addr,
|
||||||
|
controls,
|
||||||
|
cmd_tx,
|
||||||
|
handle: Some(handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn front_addr(&self) -> SocketAddr {
|
||||||
|
self.front_addr
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_drop_c2s(&self, percent: u64) {
|
||||||
|
self.controls
|
||||||
|
.drop_c2s_percent
|
||||||
|
.store(percent, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_drop_s2c(&self, percent: u64) {
|
||||||
|
self.controls
|
||||||
|
.drop_s2c_percent
|
||||||
|
.store(percent, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_dup(&self, percent: u64) {
|
||||||
|
self.controls.dup_percent.store(percent, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arm_reorder(&self) {
|
||||||
|
self.controls.reorder_next.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_impairments(&self) {
|
||||||
|
self.set_drop_c2s(0);
|
||||||
|
self.set_drop_s2c(0);
|
||||||
|
self.set_dup(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebind the relay's upstream socket; the server will see traffic from a
|
||||||
|
/// new source address afterward. Returns the new upstream local address.
|
||||||
|
fn rebind_upstream(&self) -> SocketAddr {
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
self.cmd_tx.send(RelayCmd::RebindUpstream(tx)).unwrap();
|
||||||
|
rx.recv_timeout(Duration::from_secs(2))
|
||||||
|
.expect("relay rebind ack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Relay {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.controls.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay_loop(
|
||||||
|
front: UdpSocket,
|
||||||
|
server_addr: SocketAddr,
|
||||||
|
controls: Arc<RelayControls>,
|
||||||
|
cmd_rx: Receiver<RelayCmd>,
|
||||||
|
seed: u64,
|
||||||
|
) {
|
||||||
|
let mut upstream = new_upstream();
|
||||||
|
let mut client_addr: Option<SocketAddr> = None;
|
||||||
|
let mut rng = StdRng::seed_from_u64(seed);
|
||||||
|
let mut held: Option<(Vec<u8>, bool)> = None; // (packet, is_c2s) held for reorder
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if controls.shutdown.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any pending control commands.
|
||||||
|
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||||
|
match cmd {
|
||||||
|
RelayCmd::RebindUpstream(reply) => {
|
||||||
|
upstream = new_upstream();
|
||||||
|
let _ = reply.send(upstream.local_addr().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client -> server.
|
||||||
|
match front.recv_from(&mut buf) {
|
||||||
|
Ok((n, src)) => {
|
||||||
|
client_addr = Some(src);
|
||||||
|
controls.c2s_count.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let packet = buf[..n].to_vec();
|
||||||
|
let drop_pct = controls.drop_c2s_percent.load(Ordering::SeqCst);
|
||||||
|
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
|
||||||
|
forward_with_effects(
|
||||||
|
&upstream,
|
||||||
|
server_addr,
|
||||||
|
packet,
|
||||||
|
true,
|
||||||
|
&controls,
|
||||||
|
&mut rng,
|
||||||
|
&mut held,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ref e)
|
||||||
|
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||||
|
|| e.kind() == std::io::ErrorKind::TimedOut => {}
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server -> client.
|
||||||
|
match upstream.recv_from(&mut buf) {
|
||||||
|
Ok((n, _src)) => {
|
||||||
|
controls.s2c_count.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if let Some(dst) = client_addr {
|
||||||
|
let packet = buf[..n].to_vec();
|
||||||
|
let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst);
|
||||||
|
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
|
||||||
|
forward_with_effects(
|
||||||
|
&front, dst, packet, false, &controls, &mut rng, &mut held,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ref e)
|
||||||
|
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||||
|
|| e.kind() == std::io::ErrorKind::TimedOut => {}
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_upstream() -> UdpSocket {
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(20)))
|
||||||
|
.unwrap();
|
||||||
|
socket
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward a packet to `dst` over `out`, applying duplication and reorder.
|
||||||
|
fn forward_with_effects(
|
||||||
|
out: &UdpSocket,
|
||||||
|
dst: SocketAddr,
|
||||||
|
packet: Vec<u8>,
|
||||||
|
is_c2s: bool,
|
||||||
|
controls: &RelayControls,
|
||||||
|
rng: &mut StdRng,
|
||||||
|
held: &mut Option<(Vec<u8>, bool)>,
|
||||||
|
) {
|
||||||
|
// Reorder: if armed, hold this packet and release the previously held one
|
||||||
|
// afterward (so two consecutive packets swap order).
|
||||||
|
if controls.reorder_next.swap(false, Ordering::SeqCst) {
|
||||||
|
if let Some((prev, _)) = held.take() {
|
||||||
|
let _ = out.send_to(&packet, dst);
|
||||||
|
let _ = out.send_to(&prev, dst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*held = Some((packet, is_c2s));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((prev, _)) = held.take() {
|
||||||
|
let _ = out.send_to(&prev, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = out.send_to(&packet, dst);
|
||||||
|
|
||||||
|
let dup_pct = controls.dup_percent.load(Ordering::SeqCst);
|
||||||
|
if dup_pct > 0 && rng.gen_range(0..100) < dup_pct {
|
||||||
|
let _ = out.send_to(&packet, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a bootstrap and attach through the relay, returning the client socket,
|
||||||
|
/// the bootstrap (for the session key), and the AttachOk.
|
||||||
|
fn attach_through_relay(
|
||||||
|
config: &std::path::Path,
|
||||||
|
relay: &Relay,
|
||||||
|
) -> (UdpSocket, BootstrapResponse, AttachOk) {
|
||||||
|
let config = load_server_config(Some(config.to_path_buf())).unwrap();
|
||||||
|
let secret = load_or_create_server_secret(&config).unwrap();
|
||||||
|
let bootstrap = build_bootstrap(
|
||||||
|
&config,
|
||||||
|
&secret,
|
||||||
|
"tester".to_string(),
|
||||||
|
"default".to_string(),
|
||||||
|
"read-write".to_string(),
|
||||||
|
(80, 24),
|
||||||
|
crypto::random_12(),
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||||
|
.unwrap();
|
||||||
|
let req = protocol::BootstrapAttachRequest {
|
||||||
|
bootstrap: bootstrap.clone(),
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
requested_env: Vec::new(),
|
||||||
|
};
|
||||||
|
let packet = protocol::encode_plain(
|
||||||
|
PacketKind::BootstrapAttachRequest,
|
||||||
|
[0u8; 16],
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
&protocol::to_body(&req).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Retry the attach request a few times in case the relay drops it.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
socket.send_to(&packet, relay.front_addr()).unwrap();
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
match socket.recv_from(&mut buf) {
|
||||||
|
Ok((n, _)) => {
|
||||||
|
if let Ok(decoded) = protocol::decode(&buf[..n]) {
|
||||||
|
if decoded.header.kind == PacketKind::AttachOk {
|
||||||
|
let plain = protocol::decrypt_body(
|
||||||
|
&decoded,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
SERVER_TO_CLIENT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ok: AttachOk = protocol::from_body(&plain).unwrap();
|
||||||
|
return (socket, bootstrap, ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
panic!("attach through relay timed out");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_input(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
relay: &Relay,
|
||||||
|
client_id: [u8; 16],
|
||||||
|
seq: u64,
|
||||||
|
ack: u64,
|
||||||
|
key: &[u8; 32],
|
||||||
|
text: &[u8],
|
||||||
|
) {
|
||||||
|
let input = Input {
|
||||||
|
bytes: text.to_vec(),
|
||||||
|
};
|
||||||
|
let packet = protocol::encode_encrypted(
|
||||||
|
PacketKind::Input,
|
||||||
|
client_id,
|
||||||
|
seq,
|
||||||
|
ack,
|
||||||
|
key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
&protocol::to_body(&input).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
socket.send_to(&packet, relay.front_addr()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
|
||||||
|
socket.send_to(packet, relay.front_addr()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
let (n, _) = socket.recv_from(&mut buf).ok()?;
|
||||||
|
let packet = protocol::decode(&buf[..n]).ok()?;
|
||||||
|
match packet.header.kind {
|
||||||
|
PacketKind::Frame | PacketKind::ResumeOk => {
|
||||||
|
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
|
||||||
|
let frame: Frame = protocol::from_body(&plain).ok()?;
|
||||||
|
Some((packet.header, frame))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect terminal output text for up to `millis`, returning all decoded frame
|
||||||
|
/// bytes concatenated.
|
||||||
|
fn collect_text(socket: &UdpSocket, key: &[u8; 32], millis: u64) -> String {
|
||||||
|
let prev = socket.read_timeout().unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(100)))
|
||||||
|
.unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(millis);
|
||||||
|
let mut text = String::new();
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Some((_h, frame)) = recv_frame(socket, key) {
|
||||||
|
text.push_str(&String::from_utf8_lossy(&frame.bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.set_read_timeout(prev).unwrap();
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until terminal output containing `needle` is observed, retrying for up
|
||||||
|
/// to `millis`. Returns true if seen.
|
||||||
|
fn wait_for_text(socket: &UdpSocket, key: &[u8; 32], needle: &str, millis: u64) -> bool {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(millis);
|
||||||
|
let mut acc = String::new();
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
acc.push_str(&collect_text(socket, key, 200));
|
||||||
|
if acc.contains(needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc.contains(needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_survives_packet_loss_and_reorder() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let relay = Relay::spawn(port, 0x105_5u64 ^ 0x1111);
|
||||||
|
|
||||||
|
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
|
||||||
|
|
||||||
|
// Introduce 40% loss in both directions and frequent duplication, plus
|
||||||
|
// reorder on the input flight. The server retransmits unacked frames and
|
||||||
|
// the client retransmits input, so the command must still land.
|
||||||
|
relay.set_drop_c2s(40);
|
||||||
|
relay.set_drop_s2c(40);
|
||||||
|
relay.set_dup(30);
|
||||||
|
|
||||||
|
let mut seq = 2u64;
|
||||||
|
let mut seen = false;
|
||||||
|
// Send the same logical command several times with monotonically rising
|
||||||
|
// sequence numbers (as a real client retransmitting would), interleaving a
|
||||||
|
// reorder toggle, until the output is observed despite the lossy link.
|
||||||
|
for attempt in 0..20 {
|
||||||
|
if attempt % 3 == 0 {
|
||||||
|
relay.arm_reorder();
|
||||||
|
}
|
||||||
|
send_input(
|
||||||
|
&socket,
|
||||||
|
&relay,
|
||||||
|
ok.client_id,
|
||||||
|
seq,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
b"printf DOSH_LOSSY_OK\\n\n",
|
||||||
|
);
|
||||||
|
seq += 1;
|
||||||
|
if wait_for_text(&socket, &bootstrap.session_key, "DOSH_LOSSY_OK", 400) {
|
||||||
|
seen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relay.clear_impairments();
|
||||||
|
drop(relay);
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
seen,
|
||||||
|
"terminal output never arrived across a lossy/reordering link"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicated_and_replayed_input_is_applied_at_most_once() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let relay = Relay::spawn(port, 0xD0D0u64);
|
||||||
|
|
||||||
|
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
|
||||||
|
|
||||||
|
// Append a fixed token to a file once per *distinct* delivered input. We use
|
||||||
|
// `>>` so every time the server's PTY actually executes the command, a new
|
||||||
|
// line is appended. Replay protection must ensure the duplicate/replayed
|
||||||
|
// packet at the same sequence number is NOT re-applied.
|
||||||
|
let marker = dir.path().join("dup_marker");
|
||||||
|
let cmd = format!("printf x >> {}\n", marker.display());
|
||||||
|
|
||||||
|
// Build one encrypted Input packet at a fixed sequence and send it many
|
||||||
|
// times verbatim (a true replay: identical bytes, identical seq/nonce).
|
||||||
|
let input = Input {
|
||||||
|
bytes: cmd.into_bytes(),
|
||||||
|
};
|
||||||
|
let replayed = protocol::encode_encrypted(
|
||||||
|
PacketKind::Input,
|
||||||
|
ok.client_id,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
&protocol::to_body(&input).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Also have the relay duplicate everything, to stack duplication on top of
|
||||||
|
// our explicit replays.
|
||||||
|
relay.set_dup(100);
|
||||||
|
for _ in 0..12 {
|
||||||
|
send_raw(&socket, &relay, &replayed);
|
||||||
|
thread::sleep(Duration::from_millis(40));
|
||||||
|
}
|
||||||
|
relay.set_dup(0);
|
||||||
|
|
||||||
|
// Give the PTY time to run and flush.
|
||||||
|
thread::sleep(Duration::from_millis(800));
|
||||||
|
|
||||||
|
// Drive a fence command so we know the PTY has processed at least up to here
|
||||||
|
// before we read the marker file.
|
||||||
|
send_input(
|
||||||
|
&socket,
|
||||||
|
&relay,
|
||||||
|
ok.client_id,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
b"printf DUP_FENCE\\n\n",
|
||||||
|
);
|
||||||
|
let _ = wait_for_text(&socket, &bootstrap.session_key, "DUP_FENCE", 2000);
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
|
||||||
|
let count = fs::read(&marker).map(|b| b.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
drop(relay);
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
// The replayed/duplicated identical packet must apply at most once. If
|
||||||
|
// replay protection were broken we would see many 'x' bytes.
|
||||||
|
assert!(
|
||||||
|
count <= 1,
|
||||||
|
"replayed/duplicated input was applied {count} times (expected at most 1)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_packets_after_resume_are_ignored_not_fatal() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let relay = Relay::spawn(port, 0x57A1u64);
|
||||||
|
|
||||||
|
let (old_socket, bootstrap, ok) = attach_through_relay(&config, &relay);
|
||||||
|
|
||||||
|
// Send an initial command on the original socket and confirm it lands.
|
||||||
|
send_input(
|
||||||
|
&old_socket,
|
||||||
|
&relay,
|
||||||
|
ok.client_id,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
b"printf DOSH_STALE_BEFORE\\n\n",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
wait_for_text(
|
||||||
|
&old_socket,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
"DOSH_STALE_BEFORE",
|
||||||
|
3000
|
||||||
|
),
|
||||||
|
"initial command did not land before resume"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate a reconnect from a new socket via a ResumeRequest (roaming),
|
||||||
|
// mirroring tests/integration_smoke.rs::resume_updates_udp_endpoint.
|
||||||
|
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
new_socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||||
|
.unwrap();
|
||||||
|
let resume = ResumeRequest {
|
||||||
|
session: "default".to_string(),
|
||||||
|
last_rendered_seq: ok.initial_seq,
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
};
|
||||||
|
let resume_packet = protocol::encode_encrypted(
|
||||||
|
PacketKind::ResumeRequest,
|
||||||
|
ok.client_id,
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
&protocol::to_body(&resume).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut resumed_seq = None;
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
new_socket
|
||||||
|
.send_to(&resume_packet, relay.front_addr())
|
||||||
|
.unwrap();
|
||||||
|
if let Some((_h, frame)) = recv_frame(&new_socket, &bootstrap.session_key) {
|
||||||
|
if frame.snapshot {
|
||||||
|
resumed_seq = Some(frame.output_seq);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resumed_seq = resumed_seq.expect("resume snapshot never arrived");
|
||||||
|
|
||||||
|
// Now replay a STALE packet from the OLD socket with a low sequence number
|
||||||
|
// (already-seen / out of the replay window). This must NOT terminate the
|
||||||
|
// session.
|
||||||
|
let stale = protocol::encode_encrypted(
|
||||||
|
PacketKind::Input,
|
||||||
|
ok.client_id,
|
||||||
|
2, // old, already-consumed sequence
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
&protocol::to_body(&Input {
|
||||||
|
bytes: b"printf DOSH_STALE_REPLAY\\n\n".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for _ in 0..5 {
|
||||||
|
old_socket.send_to(&stale, relay.front_addr()).unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session must remain alive on the resumed socket: a fresh command with
|
||||||
|
// a higher sequence still produces output.
|
||||||
|
send_input(
|
||||||
|
&new_socket,
|
||||||
|
&relay,
|
||||||
|
ok.client_id,
|
||||||
|
101,
|
||||||
|
resumed_seq,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
b"printf DOSH_STALE_AFTER\\n\n",
|
||||||
|
);
|
||||||
|
let alive = wait_for_text(
|
||||||
|
&new_socket,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
"DOSH_STALE_AFTER",
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(relay);
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
alive,
|
||||||
|
"session was killed by stale packets after reconnect (should be ignored, not fatal)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_source_address_change_preserves_session() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let relay = Relay::spawn(port, 0xADD12u64);
|
||||||
|
|
||||||
|
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
|
||||||
|
|
||||||
|
// Confirm the session works before the address change.
|
||||||
|
send_input(
|
||||||
|
&socket,
|
||||||
|
&relay,
|
||||||
|
ok.client_id,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
b"printf DOSH_ADDR_BEFORE\\n\n",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
wait_for_text(&socket, &bootstrap.session_key, "DOSH_ADDR_BEFORE", 3000),
|
||||||
|
"command before source-address change did not land"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch the client's source address as the server sees it by rebinding the
|
||||||
|
// relay's upstream socket (spec §11: "Connection migration must be accepted
|
||||||
|
// after any valid encrypted packet from a new source address").
|
||||||
|
let new_addr = relay.rebind_upstream();
|
||||||
|
assert_ne!(new_addr.port(), 0);
|
||||||
|
|
||||||
|
// The next valid encrypted packet now arrives from a new source address.
|
||||||
|
// The session must keep working without a re-handshake.
|
||||||
|
let mut migrated = false;
|
||||||
|
let mut seq = 3u64;
|
||||||
|
for _ in 0..12 {
|
||||||
|
send_input(
|
||||||
|
&socket,
|
||||||
|
&relay,
|
||||||
|
ok.client_id,
|
||||||
|
seq,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
b"printf DOSH_ADDR_AFTER\\n\n",
|
||||||
|
);
|
||||||
|
seq += 1;
|
||||||
|
if wait_for_text(&socket, &bootstrap.session_key, "DOSH_ADDR_AFTER", 500) {
|
||||||
|
migrated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(relay);
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
migrated,
|
||||||
|
"session did not survive a client source-address change (connection migration)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
//! 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>();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user