From 2a6f28d529894be555928e855897920f1e896dbd Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:06:27 -0400 Subject: [PATCH 1/7] Fix native-v1 connection break, server panic, and resource leaks The native-v1 work changed the UDP wire format (HEADER_LEN 42->58) without a version bump, so a current client silently timed out against the older still-deployed server. The server just needed redeploying; alongside that, fix the robustness bugs surfaced while restoring the connection: - Server: clamp terminal size to >=1 at every PTY/parser resize site. A client reporting a 0x0 terminal made vt100::Parser::set_size panic ("attempt to subtract with overflow") and took down the whole single-threaded daemon. - Client: treat size() == Ok((0,0)) like a missing size and fall back to 80x24 instead of sending 0x0 to the server. - Reap abandoned sessions and their shells. PtyHandle now retains the child and kills it on drop, and the cleanup task removes clientless, non-prewarmed sessions after the grace period. Previously the shell leaked forever (one zsh per abandoned session); prewarmed sessions still stay hot. - Evict half-finished native handshakes (pending_native) after 30s so an unauthenticated ClientHello flood can't grow the map without bound. - Drop the per-client, per-packet vt100 Screen clone on the output hot path and remove the write-only last_screen field. - Guard load_or_create_server_secret against a <32-byte secret file (was an index-out-of-range panic at startup). - Reconcile stream flow-control credit (client and server) when StreamData hits a missing writer, so a closed/unknown stream can't wedge the peer's window. Adds a unit test for session reaping. cargo fmt + cargo test green. Co-Authored-By: Claude Opus 4.8 --- src/auth.rs | 6 ++ src/bin/dosh-client.rs | 39 ++++++-- src/bin/dosh-server.rs | 202 +++++++++++++++++++++++++++++------------ src/pty.rs | 25 ++++- 4 files changed, 201 insertions(+), 71 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 184e71c..8618f09 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -68,6 +68,12 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> { .decode(String::from_utf8_lossy(&raw).trim()) .context("decode server secret")? }; + anyhow::ensure!( + decoded.len() == 32, + "server secret at {} must be 32 bytes, got {}", + path.display(), + decoded.len() + ); let mut out = [0u8; 32]; out.copy_from_slice(&decoded[..32]); return Ok(out); diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index f9526e4..e247746 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -44,6 +44,20 @@ use tokio::sync::mpsc; const STREAM_INITIAL_WINDOW: usize = 1024 * 1024; +/// Current terminal size, with a sane fallback. +/// +/// `crossterm::size()` returns `Err` when stdout is not a TTY (piped), but it +/// can also return `Ok((0, 0))` for a real PTY that has not been sized yet +/// (e.g. launched under `script` with no controlling window). Sending `0` to +/// the server is meaningless and used to crash older servers, so treat any +/// zero dimension the same as a missing size and fall back to 80x24. +fn terminal_size() -> (u16, u16) { + match size() { + Ok((cols, rows)) if cols > 0 && rows > 0 => (cols, rows), + _ => (80, 24), + } +} + #[derive(Debug, Parser)] #[command(name = "dosh-client")] struct Args { @@ -218,7 +232,7 @@ async fn main() -> Result<()> { } let dosh_port = args.dosh_port.or(host.port).unwrap_or(config.dosh_port); let cache_path = cache_path(&config.credential_cache, &requested_server, &session, &mode); - let (cols, rows) = size().unwrap_or((80, 24)); + let (cols, rows) = terminal_size(); let auth_preference = args .auth .clone() @@ -2072,7 +2086,7 @@ async fn run_terminal( let mut last_packet_at = Instant::now(); let mut status_tick = tokio::time::interval(Duration::from_secs(1)); let mut resize_tick = tokio::time::interval(Duration::from_millis(250)); - let mut last_size = size().unwrap_or((80, 24)); + let mut last_size = terminal_size(); let mut frame_buffer = FrameBuffer::default(); let mut predictor = Predictor::new(predict && cred.mode != "view-only" && !forward_only); let (forward_tx, mut forward_rx) = mpsc::channel::(1024); @@ -2151,6 +2165,8 @@ async fn run_terminal( } _ = resize_tick.tick() => { if let Ok((cols, rows)) = size() + && cols > 0 + && rows > 0 && (cols, rows) != last_size { last_size = (cols, rows); @@ -2337,17 +2353,20 @@ async fn run_terminal( continue; }; last_packet_at = Instant::now(); + let len = data.bytes.len(); if let Some(writer) = stream_writers.get_mut(&data.stream_id) { let _ = writer.write_all(&data.bytes).await; - send_stream_window_adjust( - &socket, - addr, - &cred, - &mut send_seq, - data.stream_id, - data.bytes.len(), - ).await?; } + // Always return flow-control credit so a stream whose local + // writer is already gone cannot wedge the server's send window. + send_stream_window_adjust( + &socket, + addr, + &cred, + &mut send_seq, + data.stream_id, + len, + ).await?; } PacketKind::StreamWindowAdjust => { let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 5b43a75..4fffdc2 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -28,6 +28,9 @@ use tokio::net::{TcpListener, TcpStream, UdpSocket}; use tokio::sync::mpsc; const STREAM_INITIAL_WINDOW: usize = 1024 * 1024; +/// How long a pending native handshake (ClientHello awaiting UserAuth) is kept +/// before it is swept by the cleanup task. +const NATIVE_HANDSHAKE_TTL_SECS: u64 = 30; #[derive(Debug, Parser)] #[command(name = "dosh-server")] @@ -176,6 +179,10 @@ struct Session { clients: HashMap<[u8; 16], ClientState>, output_seq: u64, recent: VecDeque>, + /// When the session last dropped to zero clients, used to reap abandoned + /// sessions (and their shells) after a grace period. `None` while at least + /// one client is attached. + empty_since: Option, } #[derive(Clone)] @@ -190,7 +197,6 @@ struct ClientState { rows: u16, last_seen: Instant, pending: VecDeque, - last_screen: Option, allowed_forwardings: Vec, stream_writers: HashMap>>, opened_streams: HashSet, @@ -272,6 +278,7 @@ impl ServerState { clients: HashMap::new(), output_seq: 0, recent: VecDeque::with_capacity(self.config.scrollback), + empty_since: None, }, ); Ok(()) @@ -282,6 +289,27 @@ fn mode_uses_pty(mode: &str) -> bool { mode != "forward-only" } +/// Resize the PTY and terminal parser for a session. +/// +/// `cols`/`rows` come straight off the wire from the client, where a terminal +/// that has not been sized yet (or a buggy/hostile client) can report `0`. +/// `vt100::Parser::set_size(0, _)` panics with a subtract overflow and would +/// take down the whole single-threaded daemon, so clamp to at least `1` here, +/// the same way `Parser::new` is constructed in `ensure_session`. +fn apply_terminal_size( + pty: Option<&PtyHandle>, + parser: &mut vt100::Parser, + cols: u16, + rows: u16, +) -> Result<()> { + let cols = cols.max(1); + let rows = rows.max(1); + pty.context("terminal session has no pty")? + .resize(cols, rows)?; + parser.set_size(rows, cols); + Ok(()) +} + fn mode_allows_terminal_updates(mode: &str) -> bool { mode != "view-only" && mode != "forward-only" } @@ -604,16 +632,10 @@ async fn handle_native_user_auth( .get_mut(&session_name) .expect("session exists"); if mode_allows_terminal_updates(&mode) { - session - .pty - .as_ref() - .context("terminal session has no pty")? - .resize(cols, rows)?; - session.parser.set_size(rows, cols); + apply_terminal_size(session.pty.as_ref(), &mut session.parser, cols, rows)?; } let client_id = crypto::random_16(); let snapshot = screen_snapshot(session.parser.screen()); - let screen = session.parser.screen().clone(); let output_seq = session.output_seq; session.clients.insert( client_id, @@ -628,7 +650,6 @@ async fn handle_native_user_auth( rows, last_seen: Instant::now(), pending: VecDeque::new(), - last_screen: Some(screen), allowed_forwardings: req.auth.requested_forwardings.clone(), stream_writers: HashMap::new(), opened_streams: HashSet::new(), @@ -734,16 +755,15 @@ async fn handle_bootstrap_attach( .get_mut(&req.bootstrap.session) .expect("session exists"); if mode_allows_terminal_updates(&req.bootstrap.mode) { - session - .pty - .as_ref() - .context("terminal session has no pty")? - .resize(req.cols, req.rows)?; - session.parser.set_size(req.rows, req.cols); + apply_terminal_size( + session.pty.as_ref(), + &mut session.parser, + req.cols, + req.rows, + )?; } let client_id = crypto::random_16(); let snapshot = screen_snapshot(session.parser.screen()); - let screen = session.parser.screen().clone(); let output_seq = session.output_seq; session.clients.insert( client_id, @@ -758,7 +778,6 @@ async fn handle_bootstrap_attach( rows: req.rows, last_seen: Instant::now(), pending: VecDeque::new(), - last_screen: Some(screen), allowed_forwardings: Vec::new(), stream_writers: HashMap::new(), opened_streams: HashSet::new(), @@ -849,16 +868,15 @@ async fn handle_ticket_attach( .get_mut(&req.session) .expect("session exists"); if mode_allows_terminal_updates(&req.mode) { - session - .pty - .as_ref() - .context("terminal session has no pty")? - .resize(req.cols, req.rows)?; - session.parser.set_size(req.rows, req.cols); + apply_terminal_size( + session.pty.as_ref(), + &mut session.parser, + req.cols, + req.rows, + )?; } let client_id = crypto::random_16(); let snapshot = screen_snapshot(session.parser.screen()); - let screen = session.parser.screen().clone(); let output_seq = session.output_seq; session.clients.insert( client_id, @@ -873,7 +891,6 @@ async fn handle_ticket_attach( rows: req.rows, last_seen: Instant::now(), pending: VecDeque::new(), - last_screen: Some(screen), allowed_forwardings: Vec::new(), stream_writers: HashMap::new(), opened_streams: HashSet::new(), @@ -965,15 +982,14 @@ async fn handle_resume( client.last_seen = Instant::now(); client.send_seq += 1; if mode_allows_terminal_updates(&client.mode) { - session - .pty - .as_ref() - .context("terminal session has no pty")? - .resize(req.cols, req.rows)?; - session.parser.set_size(req.rows, req.cols); + apply_terminal_size( + session.pty.as_ref(), + &mut session.parser, + req.cols, + req.rows, + )?; } let snapshot = screen_snapshot(session.parser.screen()); - client.last_screen = Some(session.parser.screen().clone()); (client.send_seq, session.output_seq, snapshot) }; let frame = Frame { @@ -1056,12 +1072,12 @@ async fn handle_resize( client.endpoint = peer; client.cols = resize.cols; client.rows = resize.rows; - session - .pty - .as_ref() - .context("terminal session has no pty")? - .resize(resize.cols, resize.rows)?; - session.parser.set_size(resize.rows, resize.cols); + apply_terminal_size( + session.pty.as_ref(), + &mut session.parser, + resize.cols, + resize.rows, + )?; } Ok(()) } @@ -1280,18 +1296,15 @@ async fn handle_stream_data( client.last_seen = Instant::now(); client.stream_writers.get(&data.stream_id).cloned() }; + let len = data.bytes.len(); if let Some(writer) = writer { - let len = data.bytes.len(); let _ = writer.send(data.bytes).await; - send_stream_window_adjust_to_client( - state, - socket, - packet.header.conn_id, - data.stream_id, - len, - ) - .await?; } + // Always return flow-control credit, even when the stream's writer is already + // gone (closed/unknown). The peer debited its send window for these bytes, so + // skipping the adjust would wedge the stream at a permanent credit deficit. + send_stream_window_adjust_to_client(state, socket, packet.header.conn_id, data.stream_id, len) + .await?; Ok(()) } @@ -1847,10 +1860,12 @@ async fn broadcast_output( let mut sends = Vec::new(); for (client_id, client) in session.clients.iter_mut() { client.send_seq += 1; - let current_screen = session.parser.screen().clone(); + // Only materialize a screen snapshot when this client has fallen too + // far behind; the common case sends the raw output bytes and must not + // clone the whole vt100 grid per client per packet. let (bytes, snapshot) = if client.pending.len() >= retransmit_window { client.pending.clear(); - (screen_snapshot(¤t_screen), true) + (screen_snapshot(session.parser.screen()), true) } else { (output.bytes.clone(), false) }; @@ -1874,7 +1889,6 @@ async fn broadcast_output( while client.pending.len() >= retransmit_window { client.pending.pop_front(); } - client.last_screen = Some(current_screen); client.pending.push_back(PendingFrame { output_seq, packet: packet.clone(), @@ -1977,14 +1991,49 @@ async fn retransmit_pending( } fn cleanup_disconnected_clients(state: &Arc>) { - let mut locked = state.lock().expect("server state poisoned"); - let timeout = Duration::from_secs(locked.config.client_timeout_secs.max(1)); - let now = Instant::now(); - for session in locked.sessions.values_mut() { - session - .clients - .retain(|_, client| now.duration_since(client.last_seen) <= timeout); - } + // Sessions removed here are dropped after the lock is released so their + // shells are killed (PtyHandle::drop) without holding up the event loop. + let reaped: Vec = { + let mut locked = state.lock().expect("server state poisoned"); + let timeout = Duration::from_secs(locked.config.client_timeout_secs.max(1)); + let now = Instant::now(); + let prewarm: HashSet = locked.config.prewarm_sessions.iter().cloned().collect(); + for session in locked.sessions.values_mut() { + session + .clients + .retain(|_, client| now.duration_since(client.last_seen) <= timeout); + if session.clients.is_empty() { + session.empty_since.get_or_insert(now); + } else { + session.empty_since = None; + } + } + // Evict half-finished native handshakes. Each unauthenticated ClientHello + // inserts a PendingNativeAuth that is otherwise only removed by a matching + // UserAuth, so without this a flood of hellos grows the map without bound. + let handshake_ttl = Duration::from_secs(NATIVE_HANDSHAKE_TTL_SECS); + locked + .pending_native + .retain(|_, pending| now.duration_since(pending.created_at) < handshake_ttl); + // Reap sessions that have been clientless past the grace period. Prewarmed + // sessions stay hot on purpose, even with no clients attached. + let to_reap: Vec = locked + .sessions + .iter() + .filter(|(name, session)| { + !prewarm.contains(name.as_str()) + && session + .empty_since + .is_some_and(|since| now.duration_since(since) >= timeout) + }) + .map(|(name, _)| name.clone()) + .collect(); + to_reap + .into_iter() + .filter_map(|name| locked.sessions.remove(&name)) + .collect() + }; + drop(reaped); } fn find_client_key( @@ -2030,6 +2079,41 @@ mod tests { assert!(state.sessions["forward"].pty.is_none()); } + #[test] + fn cleanup_reaps_abandoned_sessions_but_keeps_prewarmed() { + let (pty_tx, _pty_rx) = mpsc::unbounded_channel(); + let config = ServerConfig { + client_timeout_secs: 1, + prewarm_sessions: vec!["default".to_string()], + ..ServerConfig::default() + }; + let mut state = ServerState::new(config, [0u8; 32], pty_tx); + // Both sessions are clientless; use forward-only so no real shell spawns. + state + .ensure_session("default", 80, 24, "forward-only", &[]) + .unwrap(); + state + .ensure_session("abandoned", 80, 24, "forward-only", &[]) + .unwrap(); + // Mark both as empty long enough ago to be past the grace period. + let stale = Instant::now() - Duration::from_secs(60); + state.sessions.get_mut("default").unwrap().empty_since = Some(stale); + state.sessions.get_mut("abandoned").unwrap().empty_since = Some(stale); + + let state = Arc::new(Mutex::new(state)); + cleanup_disconnected_clients(&state); + + let locked = state.lock().unwrap(); + assert!( + locked.sessions.contains_key("default"), + "prewarmed session must stay hot" + ); + assert!( + !locked.sessions.contains_key("abandoned"), + "clientless non-prewarmed session must be reaped" + ); + } + #[test] fn accepted_env_filters_by_server_patterns() { let config = ServerConfig { @@ -2107,7 +2191,6 @@ mod tests { rows: 24, last_seen: Instant::now(), pending: VecDeque::new(), - last_screen: None, allowed_forwardings: Vec::new(), stream_writers: HashMap::new(), opened_streams: HashSet::new(), @@ -2117,6 +2200,7 @@ mod tests { )]), output_seq: 0, recent: VecDeque::new(), + empty_since: None, }, ); let state = Arc::new(Mutex::new(state)); diff --git a/src/pty.rs b/src/pty.rs index e20f571..2f1361c 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; +use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use std::io::{Read, Write}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -8,6 +8,7 @@ use tokio::sync::mpsc; pub struct PtyHandle { writer: Arc>>, + child: Mutex>, _master: Box, } @@ -28,6 +29,25 @@ impl PtyHandle { })?; Ok(()) } + + /// Terminate the shell process backing this PTY and reap it. + /// + /// Without this the child shell outlives the session: dropping the master + /// alone is not guaranteed to take the process down, and the `Child` handle + /// used to be discarded at spawn time, which leaked one shell per + /// abandoned session. + pub fn kill(&self) { + if let Ok(mut child) = self.child.lock() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for PtyHandle { + fn drop(&mut self) { + self.kill(); + } } #[derive(Debug)] @@ -68,7 +88,7 @@ pub fn spawn_pty_session( } else if let Some(parent) = Path::new(shell).parent() { cmd.env("PWD", parent.as_os_str()); } - let _child = pair.slave.spawn_command(cmd).context("spawn shell")?; + let child = pair.slave.spawn_command(cmd).context("spawn shell")?; drop(pair.slave); let writer = pair.master.take_writer().context("take pty writer")?; @@ -110,6 +130,7 @@ pub fn spawn_pty_session( Ok(PtyHandle { writer: Arc::new(Mutex::new(writer)), + child: Mutex::new(child), _master: pair.master, }) } From 828206f757875756adbc97a320439d3c4e1ff3c8 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:27:40 -0400 Subject: [PATCH 2/7] Publish threat model and refresh native v1 docs Add docs/THREAT_MODEL.md deriving a publishable threat model from NATIVE_V1_SPEC.md sections 4-6: assets, in/out-of-scope attackers, security properties vs SSH (including where Dosh aims to exceed it), cryptographic building blocks as implemented, and an honest accepted-residual-risks / known-gaps section. Refresh docs/PUBLIC_READINESS.md: feature matrix now reflects native auth, host-key trust, authorized_keys policy, doctor, and -L/-R/-D forwarding; add a per-item "Native v1 verification checklist status" table mapping spec section 16 to done/in-progress/pending from code. Update README.md status with a native v1 section and honest claim posture pointing to THREAT_MODEL.md. Annotate NATIVE_V1_SPEC.md with a v1 status block summarizing milestone progress. Point SPEC.md security model and header at native auth and the threat model. Co-Authored-By: Claude Opus 4.8 --- README.md | 30 +++++ SPEC.md | 10 +- docs/NATIVE_V1_SPEC.md | 25 +++++ docs/PUBLIC_READINESS.md | 103 +++++++++++++---- docs/THREAT_MODEL.md | 232 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 378 insertions(+), 22 deletions(-) create mode 100644 docs/THREAT_MODEL.md diff --git a/README.md b/README.md index 2d03663..0e581b5 100644 --- a/README.md +++ b/README.md @@ -267,3 +267,33 @@ resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP atta tickets, client ACKs, server retransmit bookkeeping, sliding replay protection, server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install script, Docker SSH benchmark gates, CI, and protocol/integration tests. + +### Native v1 + +Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substantially +implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed +servers: + +- **Native UDP auth** with X25519 key exchange, transcript-bound Ed25519 user auth + via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and + `authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`; + unsupported options fail closed). +- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`, + TOFU only when explicitly enabled, and hard-fail on host-key mismatch. +- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic + SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so + bulk transfers do not lag the terminal. +- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks. + +Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`): +the native authenticated path is tried first and falls back to SSH bootstrap +explicitly when native auth is disabled, unavailable, or rejected. It never silently +degrades to an unauthenticated mode. + +Native v1 is **not yet fully verified**. Per-IP token-bucket rate limiting, protocol +VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external +security review are still open. See `docs/THREAT_MODEL.md` for the published threat +model and accepted residual risks, and the "Native v1 verification checklist status" +table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet +claim a fully verified SSH replacement; its defensible claim remains fast encrypted +native attach/reconnect with SSH-equivalent transport security and SSH fallback. diff --git a/SPEC.md b/SPEC.md index 233c6ae..beb36a0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,8 @@ **Default language:** Rust, unless benchmarks prove the stack is the bottleneck **Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench` **Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T` -**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` +**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` (substantially implemented; see its v1 status block) +**Threat model:** `docs/THREAT_MODEL.md` --- @@ -157,8 +158,11 @@ owner per session is preferred. Cross-thread designs are allowed only if benchma ## 7. Security Model -SSH is the first trust root. dosh does not implement a competing public-key login -system in v0. +SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies +on SSH for first authentication. Native v1 (`docs/NATIVE_V1_SPEC.md`) adds an opt-in +native public-key login over the Dosh UDP transport that is tried before SSH, with +its assets, attackers, and residual risks documented in `docs/THREAT_MODEL.md`. SSH +remains the explicit fallback and the host-key trust bootstrap path. The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable speed, with `AES-GCM` allowed when hardware acceleration is known to be available. diff --git a/docs/NATIVE_V1_SPEC.md b/docs/NATIVE_V1_SPEC.md index 0c39a11..d81047a 100644 --- a/docs/NATIVE_V1_SPEC.md +++ b/docs/NATIVE_V1_SPEC.md @@ -1,5 +1,30 @@ # Dosh Native v1 Spec +> **v1 status (annotation, not part of the spec text below).** Native v1 is +> substantially implemented and being stabilized; it is not yet fully verified. +> Milestone progress against section 15: +> +> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`, +> `known_hosts`, and mismatch hard-fail are implemented. +> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/ +> `AuthOk`, ssh-agent and encrypted-key Ed25519, and `authorized_keys` verification +> exist. ECDSA/RSA user keys are still pending (Ed25519 only today). +> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is +> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are +> pending (Track C / `BENCHMARKS.md`). +> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and +> per-stream flow control are implemented; hostile-network and load tests pending. +> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting, +> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not +> yet complete. The threat model is published (`docs/THREAT_MODEL.md`). +> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust +> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is +> ongoing. +> +> The section 16 verification checklist is **not yet fully green** — see the +> item-by-item status table in `docs/PUBLIC_READINESS.md` and the residual-risk list in +> `docs/THREAT_MODEL.md`. The section 17 public-claim gate is therefore **not met**. + Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to replace the user's day-to-day `ssh host` workflow for terminals and forwarding while keeping SSH as a compatibility and recovery fallback. diff --git a/docs/PUBLIC_READINESS.md b/docs/PUBLIC_READINESS.md index 56281ac..1107f11 100644 --- a/docs/PUBLIC_READINESS.md +++ b/docs/PUBLIC_READINESS.md @@ -5,9 +5,17 @@ claim full Mosh replacement status until the feature matrix below is green and t comparison benchmark is reproducible outside the author's homelab. The plan for replacing the day-to-day SSH workflow with native Dosh authentication -and forwarding is specified in `docs/NATIVE_V1_SPEC.md`. Until that spec is -implemented and verified, Dosh's public security claim remains SSH-bootstrap plus -encrypted Dosh transport. +and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat +model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented: +native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding, +and `dosh doctor` all exist (see the feature matrix and the verification-checklist +status table below). It is **not yet fully verified**: per-IP token-bucket rate +limiting, protocol-version negotiation hardening, fuzzing in CI, and external review +are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is +green and that review is done, Dosh's defensible public security claim remains fast +encrypted native attach/reconnect with SSH-equivalent transport security and an +explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH +replacement. ## Objective Benchmarks @@ -50,6 +58,10 @@ with ordinary SSH. | Feature | Mosh | Dosh now | Public status | | --- | --- | --- | --- | | SSH-based first authentication | yes | yes | ready | +| Native UDP key auth (no SSH per attach) | no | yes, Ed25519 via ssh-agent or encrypted key | implemented; pending full verification | +| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented | +| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented | +| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented | | Encrypted UDP terminal data | yes | yes | ready | | Roaming by client address change | yes | yes | needs more hostile-network tests | | Survive sleep or network loss | yes | yes | needs long-running soak tests | @@ -66,9 +78,11 @@ with ordinary SSH. | Unicode edge-case handling | strong | basic terminal emulator dependent | not parity | | X11 forwarding | no | no | non-goal unless tunneled separately | | SSH agent forwarding | no | no | planned as forwarding channel | -| Local TCP forwarding, `-L` | no | not implemented | planned | -| Remote TCP forwarding, `-R` | no | not implemented | planned | -| Dynamic SOCKS forwarding, `-D` | no | not implemented | later | +| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests | +| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; needs hostile-network tests | +| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; needs hostile-network tests | +| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented | +| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; needs load tests | ## SSH Config Inheritance @@ -87,29 +101,77 @@ dosh import-ssh palav homelab This appends entries to `~/.config/dosh/hosts.toml` without trying to become an OpenSSH config parser. -## Forwarding Plan +## Forwarding (Implemented) SSH forwarding cannot be copied by keeping the bootstrap SSH connection open, because that would remove Dosh's fast reconnect advantage and would break after -roaming. Dosh forwarding needs native encrypted channels over the Dosh transport. - -Minimum viable forwarding design: +roaming. Dosh forwarding therefore runs as native encrypted stream channels over the +Dosh transport, and is now implemented. | CLI | Meaning | | --- | --- | | `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. | -| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server; client connects to target. | +| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server (loopback by default); client connects to target. | +| `dosh -D 1080 host` | SOCKS5 listener on the client; server connects to whatever the SOCKS request names. | -Protocol work required: +Implemented: -- Add stream-open, stream-data, stream-ack, and stream-close packet types. -- Use per-stream flow control separate from terminal frame ordering. -- Never let bulk forwarding traffic delay terminal input/output packets. -- Bind forwarding permissions to the same SSH-authenticated user as the terminal. -- Add tests with dropped/reordered UDP packets. +- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/ + `StreamEof`/`StreamClose` packet types. +- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal + frames, so bulk forwarding does not block PTY input/output. +- Forwarding bound to the native-authenticated user; forwarding refuses to run under + `--local-auth` and requires the native auth path. +- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`, + loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key + `no-port-forwarding` and `permitopen=`. +- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only + after listeners are ready). -This is a publishable differentiator once implemented, but it should not be claimed -until it exists. +Still open before claiming forwarding parity: + +- A dedicated dropped/reordered/replayed-UDP forwarding test suite. +- Load tests proving large forwarded transfers add no visible terminal input lag. + +## Native v1 Verification Checklist Status + +This maps each item in `NATIVE_V1_SPEC.md` section 16 to its status based on what the +code in `src/` actually does. Done = implemented and exercised by tests or obvious +from code; in progress = partially implemented; pending = not yet implemented. + +| Spec section 16 item | Status | Evidence / note | +| --- | --- | --- | +| Unknown host key fails unless TOFU explicitly enabled | done | Client refuses `KnownHostStatus::Unknown` unless `trust_on_first_use`. | +| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. | +| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. | +| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. | +| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. | +| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. | +| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. | +| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. | +| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. | +| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. | +| Native cold auth beats cold `ssh host true` | pending | Benchmark gate not yet run for native cold path (Track C / `BENCHMARKS.md`). | +| Cached attach near network RTT | pending | Same benchmark dependency. | +| `-L` works without delaying terminal input | in progress | Implemented with per-stream window; load proof pending. | +| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. | +| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. | +| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. | +| Multiple forwards in one command | done | Forward lists parsed and started together. | +| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. | +| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. | +| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. | +| Large forwarded transfers add no visible input lag | in progress | Per-stream flow control exists; load test pending. | +| Fuzz targets run in CI | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. | +| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. | + +Additional security hardening tracked outside the section 16 list: + +- Full per-IP token-bucket rate limiting: in progress (another track). Today only + handshake-map eviction and a static `rate_limit_remaining` hint exist. +- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no + multi-version negotiation yet. +- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today. ## Before Public Launch @@ -119,3 +181,6 @@ until it exists. bracketed paste, and terminal cleanup. - Publish benchmark output with raw samples, not just averages. - Mark prediction as experimental until it has a real framebuffer model. +- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI. +- Complete the native-v1 verification checklist above and an external security review + before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17). diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..c83dce6 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,232 @@ +# Dosh Threat Model + +This is the published threat model for Dosh native v1, derived from +`docs/NATIVE_V1_SPEC.md` sections 4-6. It states the assets Dosh protects, the +attackers it does and does not defend against, the security properties Dosh claims +relative to SSH, the cryptographic building blocks actually in use, and an honest +list of accepted residual risks and known gaps. + +Dosh is a remote-login transport for Dosh-installed servers. It is intended to +replace the day-to-day `ssh host` workflow for terminals and TCP forwarding while +keeping OpenSSH as a recovery and bootstrap fallback. Dosh is **not** an +RFC-compatible SSH implementation and does not claim SSH's entire protocol security +surface. It claims security that is **equivalent to, and in some respects stronger +than, SSH for the Dosh terminal and forwarding use case** on hosts running +`dosh-server`. + +## 1. Assets + +Dosh protects: + +- **Terminal session contents.** Keystrokes, command output, and the authoritative + screen state for every named or generated session. +- **Forwarded TCP streams.** Bytes carried over `-L`, `-R`, and `-D` channels. +- **User authentication credentials.** The user's SSH/Dosh private keys and any + ssh-agent identities. Dosh never sees private key material in plaintext on the + wire; signatures are produced locally or by the agent. +- **Server-issued credentials.** Session keys, `ClientId` association state, + server-sealed attach tickets, and the client-held attach-ticket PSK. +- **Server identity.** The persistent Dosh host key (`~/.config/dosh/host_key`) and + the server secret used to seal attach tickets and derive bootstrap material. +- **Host-trust state.** The client's pinned known-hosts file + (`~/.config/dosh/known_hosts`). +- **Authorization policy.** `~/.ssh/authorized_keys` / + `~/.config/dosh/authorized_keys` and the forwarding policy they encode. + +## 2. Attackers + +### In scope (Dosh must defend against these) + +- **Passive network observer.** May record all UDP traffic between client and + server. +- **Active network attacker.** May spoof, drop, replay, reorder, or modify any + packet, and may attempt to inject forged packets in either direction. +- **NAT rebinding / roaming.** Client source IP and port may change mid-session, + including across sleep, network switch, and NAT timeout. +- **Stolen attach-ticket cache without the user's private key.** An attacker who + reads a client cache copies a server-sealed ticket plus its PSK but does not have + the user's SSH private key. +- **Server restart and key rotation.** Stale session keys, tickets, and replay + state must not be usable after the server rotates its secret or host key. +- **Malicious unauthenticated client flooding auth attempts.** A peer that has no + authorized key tries to exhaust server resources or guess credentials. +- **Compromised low-privilege local user on a shared client.** A different local + user attempts to read Dosh credential caches on the same machine. + +### Out of scope (explicitly not defended against) + +- **Compromised client machine.** If the endpoint running `dosh-client` is owned by + the attacker, the attacker has the user's keys and terminal. +- **Compromised server account.** If the login account on the server is owned, the + attacker already has the shell Dosh would have given them. +- **Malicious kernel, terminal emulator, or PTY implementation** on either side. +- **A server that was legitimately authorized and later turns malicious.** Host-key + pinning detects a *substituted* server, not a trusted server that decides to + misbehave. + +These exclusions match SSH's own boundaries: SSH likewise cannot protect a +compromised endpoint or a malicious authorized peer. + +## 3. Security Properties Claimed vs SSH + +| Property | SSH | Dosh native v1 | Notes | +| --- | --- | --- | --- | +| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. | +| User authentication by private-key possession | yes | yes | Ed25519 via ssh-agent or encrypted OpenSSH key; signature binds the full transcript. | +| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. | +| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. | +| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. | +| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. | +| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. | +| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/Ed25519 crates only. | +| Fail-closed downgrade behavior | yes | yes | Native auth failure surfaces an explicit error and SSH fallback is explicit; it never silently drops to an unauthenticated mode. | +| Fast resumption without re-auth | ControlMaster only | yes, native | Cached session/ticket attach skips a fresh round of public-key proof; this is a deliberate speed/security trade discussed in section 6. | + +### Where Dosh aims to *exceed* SSH for this use case + +- **Tighter transcript binding.** A user-auth signature binds both ephemeral keys, + both randoms, the server host key, requested user/session/mode/terminal size, + selected algorithms, and protocol version into one transcript. This forecloses + cross-protocol and partial-replay confusion classes for the narrow Dosh surface. +- **Smaller attack surface.** Dosh deliberately omits the full SSH transport/channel + machinery, arbitrary subsystems, X11, SFTP/SCP, and forced-command subsystems. + Fewer features means fewer parsers and fewer reachable states. +- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable + known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail + mismatch path, rather than the looser default TOFU behavior most SSH clients ship. +- **Modern primitives only.** Ed25519 and X25519 by default; no DSA, no SHA-1 + signatures, no CBC-and-MAC constructions. + +Dosh does **not** claim generic SSH compatibility and must not be described as an +SSH-protocol implementation. + +## 4. Cryptographic Building Blocks (as implemented) + +These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and +`src/protocol.rs`, not just the spec. + +- **Key exchange:** X25519 ephemeral-ephemeral (`x25519-dalek`). The shared secret + is checked for contributory behaviour; a non-contributory result is rejected. +- **Handshake/transport KDF:** HKDF-SHA256 (`hkdf`), salted with the SHA-256 of the + serialized `ClientHello` and `ServerHello`, binding traffic keys to the transcript. +- **AEAD:** ChaCha20-Poly1305 (`chacha20poly1305`) for every encrypted packet and + for sealed attach tickets. Nonces are derived as `direction || sequence`, giving a + unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is + not selectable today. +- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript. +- **User-auth signatures:** Ed25519, produced either by ssh-agent over a Unix socket + (`src/ssh_agent.rs`) or from an encrypted/plaintext OpenSSH private key + (`ssh-key`). The signature covers the user-auth transcript described above. +- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256 + derived session keys, with attach tickets sealed under an HKDF-derived + ticket key. Token comparison is constant-time. +- **Hashing/transcript:** SHA-256 (`sha2`). +- **Randomness:** OS CSPRNG via `rand::thread_rng()` / `OsRng`. + +No homegrown ciphers, MACs, padding schemes, or key derivation are used. No nonce or +key pair is reused within a direction. + +## 5. How the Properties Are Enforced (handshake and transport) + +- **Server authentication.** `ServerHello` carries the host public key and an Ed25519 + signature over `dosh/native/server-hello/v1 || ClientHello || ServerHello(unsigned)`. + The client verifies this signature *and* checks the host key against its pinned + known-hosts entry before sending user auth or accepting terminal bytes. Unknown + keys are refused unless TOFU is enabled; mismatches hard-fail with both + fingerprints and the file path. +- **User authentication.** `UserAuth` carries an Ed25519 signature over + `dosh/native/user-auth/v1 || ClientHello || ServerHello || UserAuth(unsigned)`. The + server looks the public key up in `authorized_keys`, enforces authorized-key + options, then verifies the signature. A removed key can no longer authenticate. +- **Authorization options.** `from=` (with CIDR, glob, and negation), + `no-port-forwarding`, and `permitopen=` are enforced. `command=` is rejected for + native terminal login. Any unrecognized restrictive option fails closed rather than + being silently ignored. +- **Forwarding policy.** The server enforces `allow_tcp_forwarding`, + `allow_remote_forwarding`, and a loopback-only default for remote binds + (`allow_remote_non_loopback_bind`), in addition to per-key `permitopen=` / + `no-port-forwarding`. +- **Replay protection.** A 128-wide sliding window over the per-direction packet + counter rejects duplicates and stale sequences. Sequence 0 is never accepted. +- **Stale-key tolerance.** Each encrypted packet carries a `session_key_id`; packets + under an old key are dropped as "stale or wrong session key id" rather than treated + as fatal decrypt failures, so reconnect after rotation is non-destructive. +- **Roaming.** The server keys clients by `ClientId` and session key id, not by + source address, and updates the endpoint after any valid encrypted packet from a + new address — without weakening authentication, because the packet must still + decrypt and verify. + +## 6. Accepted Residual Risks and Known Gaps + +These are stated openly so the public claim gate (`NATIVE_V1_SPEC.md` section 17) can +be evaluated honestly. Items here are *not* yet "green". + +### Accepted residual risks (by design) + +- **Attach tickets prove recent server-issued possession, not fresh private-key + possession.** A stolen client cache containing both the sealed ticket and its PSK + can attach until the ticket expires (default TTL 24h, configurable down to zero) or + until the server rotates its secret/host key or the user key is removed. This is the + deliberate speed trade. It is bounded by TTL, scoped to host/user/session/mode, and + can be disabled. SSH ControlMaster has an analogous live-socket exposure. +- **Local cache confidentiality relies on filesystem permissions.** Host keys, + server secret, known-hosts, and credential caches are written `0600`. A + same-machine attacker who can already read another user's `0600` files (e.g. via + root) is out of scope, as with SSH's `~/.ssh`. +- **A trusted server that turns malicious is not detected.** Host-key pinning + detects substitution, not betrayal by an already-authorized server. This matches + SSH. + +### Known gaps / work in progress (must close before the public claim) + +- **Per-IP rate limiting is partial.** The server evicts half-finished native + handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map + without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`. + A full per-source token-bucket limiter is **in progress on another track** and is + not yet enforced. Until it lands, sustained auth flooding is mitigated only by the + handshake-eviction TTL and OS-level limits. +- **Protocol VERSION negotiation is being hardened.** The wire format pins a single + protocol version: the packet header rejects any non-matching `VERSION` byte and the + native handshake rejects any non-matching `protocol_version`. There is no + multi-version negotiation yet, so cross-version interop and downgrade-resistance for + future versions are still being designed. Today's behavior is fail-closed (reject), + not silent downgrade. +- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release + build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing, + authorized-key parsing, known-host parsing, and handshake state (spec milestone 5) + are **being wired in** and are not yet running in CI. +- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA + P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and + produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not + a weakening of what *is* supported. +- **Hostile-network and long-soak integration tests are partial.** Roaming, + retransmit, resize, and multi-client tests exist; a dedicated adversarial + drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being + expanded. +- **No external security review yet.** The spec's milestone 5 requires an external + review checklist before public security claims. That review has not happened. + +### Auth posture + +Native auth is **opt-in alongside SSH fallback**. `auth_preference` defaults to +`native,ssh`: Dosh tries the native authenticated path first and falls back to SSH +bootstrap explicitly and visibly when native auth is disabled, unavailable, or +rejected. Native auth failure never silently degrades to an unauthenticated mode. +Forwarding (`-L`/`-R`/`-D`) requires the native authenticated path and refuses to run +under `--local-auth`. + +## 7. Verification and Public-Claim Status + +Dosh may claim "native SSH replacement for Dosh-installed servers" only after the +conditions in `NATIVE_V1_SPEC.md` section 17 are met: native auth default on a real +host, SSH fallback available, the section 16 checklist green, this threat model +published, and benchmarks with raw samples for SSH cold, Dosh native cold, Dosh +cached attach, and Mosh startup. + +Current status: this threat model is published (this document). The verification +checklist is **not yet fully green** — see the item-by-item status table in +`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known +gaps in section 6 above. Until the gaps close and an external review is complete, +Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with +SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified, +externally reviewed SSH replacement. From 9b0f09a8a8aadf1ac2e0803455dbd66787c07df7 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:31:47 -0400 Subject: [PATCH 3/7] Add hostile-network tests, parser fuzzing, and CI fuzz job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 32 + fuzz/.gitignore | 5 + fuzz/Cargo.toml | 64 ++ fuzz/README.md | 59 ++ fuzz/fuzz_targets/attach_ticket.rs | 20 + fuzz/fuzz_targets/authorized_keys.rs | 18 + fuzz/fuzz_targets/from_body.rs | 62 ++ fuzz/fuzz_targets/handshake_structs.rs | 44 ++ fuzz/fuzz_targets/known_hosts.rs | 14 + fuzz/fuzz_targets/packet_decode.rs | 23 + tests/hostile_network.rs | 781 +++++++++++++++++++++++++ tests/parser_robustness.rs | 415 +++++++++++++ 12 files changed, 1537 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/fuzz_targets/attach_ticket.rs create mode 100644 fuzz/fuzz_targets/authorized_keys.rs create mode 100644 fuzz/fuzz_targets/from_body.rs create mode 100644 fuzz/fuzz_targets/handshake_structs.rs create mode 100644 fuzz/fuzz_targets/known_hosts.rs create mode 100644 fuzz/fuzz_targets/packet_decode.rs create mode 100644 tests/hostile_network.rs create mode 100644 tests/parser_robustness.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b9266..0e1abe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,38 @@ jobs: - name: Docker SSH benchmark gate 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: runs-on: ubuntu-latest env: diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..a854700 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,5 @@ +/target +/corpus +/artifacts +/coverage +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..9f50da8 --- /dev/null +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..8f57b28 --- /dev/null +++ b/fuzz/README.md @@ -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::` 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. diff --git a/fuzz/fuzz_targets/attach_ticket.rs b/fuzz/fuzz_targets/attach_ticket.rs new file mode 100644 index 0000000..907f5f0 --- /dev/null +++ b/fuzz/fuzz_targets/attach_ticket.rs @@ -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); + } +}); diff --git a/fuzz/fuzz_targets/authorized_keys.rs b/fuzz/fuzz_targets/authorized_keys.rs new file mode 100644 index 0000000..82c2a72 --- /dev/null +++ b/fuzz/fuzz_targets/authorized_keys.rs @@ -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); + } +}); diff --git a/fuzz/fuzz_targets/from_body.rs b/fuzz/fuzz_targets/from_body.rs new file mode 100644 index 0000000..118e8c4 --- /dev/null +++ b/fuzz/fuzz_targets/from_body.rs @@ -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); +}); diff --git a/fuzz/fuzz_targets/handshake_structs.rs b/fuzz/fuzz_targets/handshake_structs.rs new file mode 100644 index 0000000..6ec444e --- /dev/null +++ b/fuzz/fuzz_targets/handshake_structs.rs @@ -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 = protocol::from_body(chunk_client).ok(); + let server: Option = protocol::from_body(chunk_server).ok(); + let auth: Option = 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); + } + } +}); diff --git a/fuzz/fuzz_targets/known_hosts.rs b/fuzz/fuzz_targets/known_hosts.rs new file mode 100644 index 0000000..dd7544b --- /dev/null +++ b/fuzz/fuzz_targets/known_hosts.rs @@ -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); + } +}); diff --git a/fuzz/fuzz_targets/packet_decode.rs b/fuzz/fuzz_targets/packet_decode.rs new file mode 100644 index 0000000..5f40f50 --- /dev/null +++ b/fuzz/fuzz_targets/packet_decode.rs @@ -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); + } +}); diff --git a/tests/hostile_network.rs b/tests/hostile_network.rs new file mode 100644 index 0000000..2d8b6ea --- /dev/null +++ b/tests/hostile_network.rs @@ -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), +} + +/// 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, + cmd_tx: Sender, + handle: Option>, +} + +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::(); + + 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, + cmd_rx: Receiver, + seed: u64, +) { + let mut upstream = new_upstream(); + let mut client_addr: Option = None; + let mut rng = StdRng::seed_from_u64(seed); + let mut held: Option<(Vec, 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, + is_c2s: bool, + controls: &RelayControls, + rng: &mut StdRng, + held: &mut Option<(Vec, 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)" + ); +} diff --git a/tests/parser_robustness.rs b/tests/parser_robustness.rs new file mode 100644 index 0000000..6380b32 --- /dev/null +++ b/tests/parser_robustness.rs @@ -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(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 { + 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::(); + let _ = std::mem::size_of::(); +} From c48f2280a07c58e8904762cf841a92b0da463982 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:33:37 -0400 Subject: [PATCH 4/7] Add benchmark path matrix and safe local harness Extend dosh-bench to benchmark the full attach path matrix and emit both raw per-iteration samples and summary statistics (count/min/median/p95/mean/max): - --cold-native: native cold auth terminal-ready (dosh_cold_native_ms) - --cached-ticket: cached attach-ticket fast path (dosh_cached_attach_ms) - --resume: UDP resume path (dosh_resume_ms; roaming-only, see docs) - --local-auth: self-contained local bootstrap, no SSH (dosh_local_attach_ms) - default: legacy SSH-bootstrap cold attach (dosh_attach_ms) Existing ssh-bootstrap/local-auth/mosh modes and all assert gates are preserved; legacy flags (--local-auth/--warm-cache/--no-cache) keep their prior behavior so the CI docker scripts run unchanged. Add --json (machine-readable raw samples) and --label. Add a table/JSON renderer and percentile-based summary stats. Add scripts/bench-local.sh: a safe, self-contained harness that builds release, spins up a throwaway dosh-server bound to 127.0.0.1 on a random free UDP port in a temp HOME (never port 50000, never a systemd unit), runs the matrix, prints results, sanity-checks native auth actually trusted the host, and cleans up. Point `make bench-local` at the new harness and add `make bench-local-json`; keep bench-docker-* targets intact. Add docs/BENCHMARKS.md: how to run each benchmark, metric meanings, methodology and caveats (resume is the roaming path, not cold reconnect; cite cached attach as the core claim per PUBLIC_READINESS.md), plus a real loopback sample table with machine/OS/sample-count and raw samples. Co-Authored-By: Claude Opus 4.8 --- Makefile | 18 +- docs/BENCHMARKS.md | 173 +++++++++++++++ scripts/bench-local.sh | 192 +++++++++++++++++ src/bin/dosh-bench.rs | 467 +++++++++++++++++++++++++++++++++-------- 4 files changed, 751 insertions(+), 99 deletions(-) create mode 100644 docs/BENCHMARKS.md create mode 100755 scripts/bench-local.sh diff --git a/Makefile b/Makefile index 2d90a7e..fd7f2d6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test fmt install bench-local bench-docker-ssh bench-docker-mosh +.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh build: cargo build --release @@ -12,14 +12,16 @@ fmt: install: sh packaging/install.sh +# Safe, self-contained local benchmark matrix (native cold auth, cached attach +# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in +# a temp HOME. Never touches the production server or UDP port 50000. bench-local: - cargo build - tmp="$$(mktemp -d)"; \ - HOME="$$tmp" target/debug/dosh-server serve >/tmp/dosh-bench-server.log 2>&1 & \ - pid="$$!"; \ - trap 'kill "$$pid" 2>/dev/null || true; rm -rf "$$tmp"' EXIT INT TERM; \ - sleep 0.5; \ - HOME="$$tmp" target/debug/dosh-bench --local-auth --server local --iterations 5 + sh scripts/bench-local.sh + +# Same matrix, machine-readable JSON output (one object per metric with raw +# samples). Useful for publishing or regression tracking. +bench-local-json: + DOSH_BENCH_JSON=1 sh scripts/bench-local.sh bench-docker-ssh: sh scripts/ci-docker-ssh-bench.sh diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 0000000..4f746b0 --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,173 @@ +# Dosh Benchmarks + +This document explains how to run the Dosh benchmarks, what each metric means, +and how to read the results honestly. Dosh's headline claim is *insane speed on +repeat attach/reconnect* — these benchmarks exist to make that claim measurable +and reproducible. + +> **Read first:** `docs/PUBLIC_READINESS.md`. The defensible public claim is fast +> attach/reconnect, not full Mosh feature parity, and **not** cold startup. Cite +> `dosh_cached_attach_ms` for the core speed claim; cite cold metrics only to show +> the fallback path stays competitive with ordinary SSH. + +## TL;DR + +```bash +make bench-local # safe, self-contained matrix on a throwaway server +make bench-local-json # same, machine-readable JSON with raw samples +make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI +make bench-docker-mosh # same, with Mosh installed for a three-way comparison +``` + +`make bench-local` never touches a running production server: it builds release +binaries, starts its own `dosh-server` bound to `127.0.0.1` on a random free UDP +port inside a temporary `HOME`, runs the matrix, prints raw samples plus summary +statistics, and tears everything down on exit. It will refuse to bind UDP port +`50000` (the default production port). + +## The benchmark binary: `dosh-bench` + +`dosh-bench` spawns the real `dosh-client` once per iteration and times the whole +process from launch to terminal-ready-and-detached. That wall-clock cost is the +apples-to-apples comparison against `ssh host true`: it is the startup price each +tool pays before any useful remote work begins. + +Every run prints, per metric, a summary line (count, min, median, p95, mean, max +in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable +object per run (with the full `samples_ms` array) so published numbers can always +include raw data, as required by `docs/PUBLIC_READINESS.md`. + +### Path matrix + +`dosh-bench` can benchmark these Dosh attach paths. Without an explicit path flag +it keeps its legacy single-path behavior (driven by `--local-auth` / `--warm-cache` +/ `--no-cache`) so existing CI scripts keep working. + +| Flag | Metric | What it measures | +| --- | --- | --- | +| `--cold-native` | `dosh_cold_native_ms` | Native cold auth: full `--auth native --no-cache` handshake to first frame and detach. The cold fallback when no cache exists. | +| `--cached-ticket` | `dosh_cached_attach_ms` | Cached attach-ticket fast path. Warms the cache once, then measures repeat attaches using cached UDP credentials/tickets. **This is the core speed claim.** | +| `--resume` | `dosh_resume_ms` | UDP resume of a session whose endpoint changes. See the caveat below — only meaningful when a live session is kept open out-of-band. | +| `--local-auth` | `dosh_local_attach_ms` | Self-contained local bootstrap; no SSH, no native handshake. Useful as a lower-bound sanity check on loopback. | +| *(default, no flag)* | `dosh_attach_ms` | Cold SSH bootstrap plus UDP attach (legacy single-path mode). | + +Existing baseline/comparison metrics are unchanged: + +| Metric | Meaning | +| --- | --- | +| `ssh_true_ms` | `ssh host true` with the same key/options. | +| `mosh_start_true_ms` | `mosh host -- true` bootstrap, run `true`, exit (timed inside a PTY). | + +### Assertions / gates + +| Flag | Gate | +| --- | --- | +| `--assert-ssh-plus-ms N` | The primary Dosh metric must be ≤ `ssh_true_ms` mean + `N` ms. | +| `--assert-mosh-minus-ms N` | The primary Dosh metric must be at least `N` ms faster than `mosh_start_true_ms`. | +| `--assert-dosh-max-ms N` | The primary Dosh metric mean must be ≤ `N` ms. | + +The "primary Dosh metric" for assertions is, in priority order: +`dosh_cached_attach_ms` → `dosh_resume_ms` → `dosh_cold_native_ms` → +`dosh_attach_ms` → `dosh_local_attach_ms`. + +## What each benchmark does + +### `make bench-local` (`scripts/bench-local.sh`) + +Self-contained, no SSH, no Docker. It: + +1. Builds release binaries (`DOSH_BENCH_PROFILE=debug` for the debug profile). +2. Picks a free UDP port (never `50000`) and a temp `HOME`. +3. Generates a throwaway client identity (`ssh-keygen`), authorizes it on the + server, and writes a throwaway server + client config (native auth, + trust-on-first-use, localhost UDP). +4. Starts `dosh-server serve` bound to `127.0.0.1` on that port. +5. Runs `dosh-bench --cold-native --cached-ticket`, then `--local-auth --no-cache`. +6. Sanity-checks that native cold auth actually trusted the host (so a silent + config-parse fallback can't make the benchmark measure the wrong path). +7. Kills the server and removes the temp `HOME` on exit. + +Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1`, +`DOSH_BENCH_PROFILE=release|debug`. + +### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`) + +Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the +`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster `dosh_attach_ms`, +`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same +container, key, and loopback network path. This is the gate CI enforces; it asserts +cold Dosh stays within 500 ms of SSH and that cached attach stays under a small +budget. See `README.md` "Develop" for the exact invocations. + +## Methodology and caveats + +- **Same machine, same network path.** `bench-local` runs everything on loopback, + so it isolates Dosh's own process/handshake/render overhead and removes network + RTT from the comparison. Real-world cached attach is approximately *network RTT + plus the local overhead these numbers measure*. Publish the machine, OS, CPU, + network path, and sample count alongside any numbers. +- **Wall-clock of the whole client process.** Each sample includes process spawn, + attach, first-frame render, and detach. That is deliberately the same thing + `ssh host true` pays, but it means a few ms is fixed process-startup cost, not + protocol cost. +- **Do not cite cold metrics as the headline.** `dosh_cold_native_ms` and + `dosh_attach_ms` still pay first-authentication cost. They exist to show the + fallback path stays competitive with ordinary SSH. Cite `dosh_cached_attach_ms` + for repeat attach/reconnect, per `docs/PUBLIC_READINESS.md`. +- **UDP resume is the roaming path, not cold reconnect.** Resume reconnects a + client that is *still attached* when its network endpoint changes (sleep/Wi-Fi + switch). The `--attach-only` benchmark model detaches after every iteration, + which removes the client on the server, so a fresh-process resume has nothing + live to resume and is not meaningful in `bench-local`. The cold fresh-process + reconnect fast path is the attach ticket (`dosh_cached_attach_ms`). Roaming + resume correctness is covered by the integration test + `resume_updates_udp_endpoint_for_roaming` in `tests/integration_smoke.rs`. + `dosh-bench --resume` remains available for scenarios that keep a live session + open out-of-band (for example remote soak tests). +- **These are not identical workloads.** SSH/Mosh/Dosh do different work at + startup. The comparison is still useful because it measures the startup tax each + tool charges before useful remote work begins. + +## Sample results (loopback, self-contained) + +Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in +milliseconds, 30 samples per metric. + +- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores +- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64 +- Toolchain: rustc 1.96.0, release profile +- Network path: loopback (`127.0.0.1`), throwaway server on a free UDP port +- Workload: `dosh-client --attach-only` to first frame and detach + +| Metric | n | min | median | p95 | mean | max | +| --- | --- | --- | --- | --- | --- | --- | +| `dosh_cold_native_ms` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 | +| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 | +| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 | + +Raw samples (ms): + +``` +dosh_cold_native_ms: +8.32, 8.23, 10.31, 10.37, 8.83, 8.81, 9.20, 9.04, 10.14, 8.98, 10.82, 9.87, +10.22, 10.42, 9.76, 9.09, 9.19, 8.94, 8.69, 8.67, 8.74, 9.33, 9.25, 8.92, 8.39, +8.55, 8.38, 9.81, 8.17, 8.10 + +dosh_cached_attach_ms: +2.99, 2.99, 2.93, 2.87, 2.95, 2.80, 2.80, 2.90, 3.10, 2.73, 3.19, 2.80, 2.87, +2.82, 2.89, 3.03, 3.06, 3.21, 2.97, 2.83, 3.00, 2.82, 3.04, 2.97, 3.03, 3.24, +3.30, 2.73, 2.79, 3.25 + +dosh_local_attach_ms: +3.05, 3.00, 2.84, 3.04, 3.33, 2.89, 2.76, 2.74, 2.73, 3.32, 3.14, 2.75, 2.83, +2.88, 2.99, 2.77, 2.75, 2.79, 2.69, 2.78, 2.75, 2.77, 2.75, 2.77, 3.03, 2.75, +2.94, 2.74, 2.82, 2.66 +``` + +Reading these numbers: cold native auth is ~9 ms because it pays the native +handshake; cached attach is ~3 ms because it reuses cached credentials and skips +the handshake entirely. On loopback there is no network RTT, so this is the local +overhead floor. Over a real link, expect cached attach ≈ this floor + one network +round trip — which is exactly the "near network RTT" target in the spec. There is +no SSH/Mosh baseline in this loopback table because those paths need an SSH server; +use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head. diff --git a/scripts/bench-local.sh b/scripts/bench-local.sh new file mode 100755 index 0000000..75db156 --- /dev/null +++ b/scripts/bench-local.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env sh +# Safe, self-contained local Dosh benchmark harness. +# +# Spins up a THROWAWAY dosh-server bound to 127.0.0.1 on a random free port in a +# temp HOME, runs the full path matrix (native cold auth, cached attach-ticket, +# UDP resume, and local-auth), prints raw samples + summary stats, then tears +# everything down. +# +# It NEVER touches the production server: it never uses UDP port 50000, never +# restarts any systemd unit, and never reads or writes the real ~/.config/dosh +# or ~/.local. Everything lives under a mktemp HOME that is removed on exit. +# +# Usage: +# scripts/bench-local.sh [ITERATIONS] +# Environment: +# DOSH_BENCH_ITERS iteration count (default 20; overridden by $1) +# DOSH_BENCH_JSON=1 emit machine-readable JSON instead of the table +# DOSH_BENCH_PROFILE release|debug build profile (default release) +set -eu + +iters="${1:-${DOSH_BENCH_ITERS:-20}}" +profile="${DOSH_BENCH_PROFILE:-release}" + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +# Make sure cargo is reachable in non-login shells. +if ! command -v cargo >/dev/null 2>&1; then + # shellcheck disable=SC1090 + [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" +fi + +if [ "$profile" = "release" ]; then + cargo build --release >&2 + bindir="$repo_root/target/release" +else + cargo build >&2 + bindir="$repo_root/target/debug" +fi +server_bin="$bindir/dosh-server" +client_bin="$bindir/dosh-client" +bench_bin="$bindir/dosh-bench" + +# Pick a free UDP port that is NOT the production port (50000). +free_udp_port() { + python3 - <<'PY' +import socket +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.bind(("127.0.0.1", 0)) +print(s.getsockname()[1]) +s.close() +PY +} +dosh_port="$(free_udp_port)" +if [ "$dosh_port" = "50000" ]; then + dosh_port="$(free_udp_port)" +fi +if [ "$dosh_port" = "50000" ]; then + echo "refusing to bind production port 50000" >&2 + exit 1 +fi + +home="$(mktemp -d)" +server_pid="" +cleanup() { + if [ -n "$server_pid" ]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf "$home" +} +trap cleanup EXIT INT TERM + +mkdir -p "$home/.config/dosh" "$home/.ssh" "$home/.local/share/dosh" + +# Client identity used by the native cold-auth path. +ssh-keygen -t ed25519 -N "" -q -f "$home/.ssh/id_ed25519" +client_pub="$(cat "$home/.ssh/id_ed25519.pub")" +printf '%s\n' "$client_pub" > "$home/authorized_keys" + +# Throwaway server config: localhost only, throwaway port, attach tickets on. +cat > "$home/.config/dosh/server.toml" < "$home/.config/dosh/client.toml" <"$home/server.log" 2>&1 & + server_pid="$!" + # Wait for the UDP socket to come up. + i=0 + while [ "$i" -lt 50 ]; do + if grep -q "listening on" "$home/server.log" 2>/dev/null; then + return 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + echo "dosh-server exited early:" >&2 + cat "$home/server.log" >&2 || true + exit 1 + fi + i=$((i + 1)) + sleep 0.1 + done +} + +run_bench() { + # $@ extra args forwarded to dosh-bench + json_flag="" + [ "${DOSH_BENCH_JSON:-0}" = "1" ] && json_flag="--json" + label="$(uname -s) $(uname -m), profile=$profile" + HOME="$home" "$bench_bin" \ + --client "$client_bin" \ + --server local \ + --dosh-host 127.0.0.1 \ + --dosh-port "$dosh_port" \ + --session default \ + --skip-ssh-baseline \ + --iterations "$iters" \ + --label "$label" \ + $json_flag \ + "$@" +} + +echo "dosh local benchmark: port=$dosh_port iters=$iters profile=$profile home=$home" >&2 + +# 1) Native cold auth + cached attach-ticket in one run (ticket cache on). +write_client_config true +start_server +echo "== native cold auth + cached attach-ticket ==" >&2 +run_bench --cold-native --cached-ticket +# Sanity: native cold auth must have trusted the host (proves the generated +# client config loaded and the native handshake ran instead of silently +# falling back to a different path). +if [ ! -s "$home/.config/dosh/known_hosts" ]; then + echo "native cold auth did not record a trusted host; check server.log:" >&2 + cat "$home/server.log" >&2 || true + exit 1 +fi +kill "$server_pid" 2>/dev/null || true +wait "$server_pid" 2>/dev/null || true +server_pid="" + +# 2) Self-contained local-auth path (no SSH, no native handshake). +# +# Note on UDP resume: resume is the roaming path for a client that is STILL +# attached when its network endpoint changes. The `--attach-only` benchmark +# model detaches after each iteration, which tears the client down on the +# server, so a fresh-process resume has nothing live to resume and is not +# meaningful here. The cold fresh-process reconnect fast path is the attach +# ticket (measured above). Roaming resume is validated by the integration test +# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for +# scenarios that keep a live session out-of-band (e.g. remote soak tests); see +# docs/BENCHMARKS.md. +rm -rf "$home/.local/share/dosh/credentials" +write_client_config true +start_server +echo "== local-auth (no SSH) ==" >&2 +run_bench --local-auth --no-cache diff --git a/src/bin/dosh-bench.rs b/src/bin/dosh-bench.rs index 88e69e2..0d1c8d2 100644 --- a/src/bin/dosh-bench.rs +++ b/src/bin/dosh-bench.rs @@ -25,6 +25,15 @@ struct Args { iterations: usize, #[arg(long)] local_auth: bool, + /// Benchmark native cold auth (no cache, native handshake) terminal-ready time. + #[arg(long)] + cold_native: bool, + /// Benchmark cached attach-ticket terminal-ready time (warms the cache first). + #[arg(long)] + cached_ticket: bool, + /// Benchmark UDP resume terminal-ready time (warms the cache first). + #[arg(long)] + resume: bool, #[arg(long)] client: Option, #[arg(long, default_value = "~/.local/bin/dosh-auth")] @@ -51,6 +60,12 @@ struct Args { mosh_server_command: String, #[arg(long)] mosh_port: Option, + /// Emit machine-readable JSON (one object per metric, with raw samples). + #[arg(long)] + json: bool, + /// Optional label printed in summary/JSON output (e.g. machine/OS identifier). + #[arg(long)] + label: Option, #[arg(long)] assert_ssh_plus_ms: Option, #[arg(long)] @@ -59,12 +74,45 @@ struct Args { assert_dosh_max_ms: Option, } +/// One Dosh attach path to benchmark. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DoshPath { + /// `--auth native --no-cache`: full native handshake, no cache. + ColdNative, + /// Cached attach-ticket fast path (requires a warmed cache). + CachedTicket, + /// UDP resume fast path (requires a warmed cache + `cache_attach_tickets = false`). + Resume, + /// `--local-auth`: self-contained local bootstrap, no SSH. + LocalAuth, + /// SSH-bootstrap cold attach (cache controlled by `--no-cache` / `--warm-cache`). + SshBootstrap, +} + +impl DoshPath { + fn metric(self) -> &'static str { + match self { + DoshPath::ColdNative => "dosh_cold_native_ms", + DoshPath::CachedTicket => "dosh_cached_attach_ms", + DoshPath::Resume => "dosh_resume_ms", + DoshPath::LocalAuth => "dosh_local_attach_ms", + DoshPath::SshBootstrap => "dosh_attach_ms", + } + } + + /// Whether this path consumes a warmed credential cache. + fn needs_warm(self) -> bool { + matches!(self, DoshPath::CachedTicket | DoshPath::Resume) + } +} + fn main() -> Result<()> { let args = Args::parse(); let client = args.client.clone().unwrap_or_else(default_client_path); - let mut ssh_times = Vec::new(); - let mut dosh_times = Vec::new(); - let mut mosh_times = Vec::new(); + if args.no_cache && args.warm_cache { + return Err(anyhow!("--warm-cache cannot be used with --no-cache")); + } + let generated_control_path = if args.controlmaster { Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id()))) } else { @@ -78,96 +126,112 @@ fn main() -> Result<()> { } else { None }; - if args.no_cache && args.warm_cache { - return Err(anyhow!("--warm-cache cannot be used with --no-cache")); - } - let dosh_label = if args.warm_cache { - let _ = time_dosh_attach(&client, &args, control_path)?; - "dosh_cached_attach_ms" + + // Resolve which Dosh paths to benchmark. Explicit path flags select an + // explicit matrix; otherwise fall back to the legacy single-path behavior + // so existing callers (CI docker scripts) keep working unchanged. + let explicit_paths = explicit_dosh_paths(&args); + let dosh_paths = if explicit_paths.is_empty() { + vec![legacy_dosh_path(&args)] } else { - "dosh_attach_ms" + explicit_paths }; - for _ in 0..args.iterations.max(1) { - if !args.local_auth && !args.skip_ssh_baseline { + let mut results: Vec = Vec::new(); + + // SSH baseline (shared across the matrix; the comparison is per-iteration + // ssh-true vs the dosh path startup that replaces it). + let run_ssh = !args.local_auth && !args.skip_ssh_baseline && !dosh_only(&dosh_paths); + if run_ssh { + let mut ssh_times = Vec::new(); + for _ in 0..args.iterations.max(1) { let mut ssh = Command::new("ssh"); add_ssh_options(&mut ssh, &args, control_path); ssh.arg(&args.server).arg("true"); ssh_times.push(time_command(&mut ssh)?); } + results.push(MetricSamples::new("ssh_true_ms", ssh_times)); + } - dosh_times.push(time_dosh_attach(&client, &args, control_path)?); + for path in &dosh_paths { + if path.needs_warm() { + // Prime the cache with one attach so the fast path has credentials. + let _ = time_dosh_attach(&client, &args, *path, control_path, true)?; + } + let mut samples = Vec::new(); + for _ in 0..args.iterations.max(1) { + samples.push(time_dosh_attach( + &client, + &args, + *path, + control_path, + false, + )?); + } + results.push(MetricSamples::new(path.metric(), samples)); + } - if args.include_mosh { + if args.include_mosh { + let mut mosh_times = Vec::new(); + for _ in 0..args.iterations.max(1) { mosh_times.push(time_mosh_in_pty(&args)?); } + results.push(MetricSamples::new("mosh_start_true_ms", mosh_times)); } - if !ssh_times.is_empty() { - println!( - "ssh_true_ms avg={:.2} samples={:?}", - avg_ms(&ssh_times), - ssh_times - ); - } - println!( - "{dosh_label} avg={:.2} samples={:?}", - avg_ms(&dosh_times), - dosh_times - ); - if !mosh_times.is_empty() { - println!( - "mosh_start_true_ms avg={:.2} samples={:?}", - avg_ms(&mosh_times), - mosh_times - ); - } - if let Some(margin) = args.assert_ssh_plus_ms { - if ssh_times.is_empty() { - return Err(anyhow!( - "--assert-ssh-plus-ms requires non-local SSH benchmark" - )); - } - let ssh_avg = avg_ms(&ssh_times); - let dosh_avg = avg_ms(&dosh_times); - if dosh_avg > ssh_avg + margin { - return Err(anyhow!( - "dosh attach avg {dosh_avg:.2}ms exceeded ssh avg {ssh_avg:.2}ms + {margin:.2}ms" - )); - } - println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms"); - } - if let Some(margin) = args.assert_mosh_minus_ms { - if mosh_times.is_empty() { - return Err(anyhow!( - "--assert-mosh-minus-ms requires --include-mosh benchmark" - )); - } - let dosh_avg = avg_ms(&dosh_times); - let mosh_avg = avg_ms(&mosh_times); - if dosh_avg + margin > mosh_avg { - return Err(anyhow!( - "dosh attach avg {dosh_avg:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh_avg:.2}ms" - )); - } - println!("gate ok: dosh avg {dosh_avg:.2}ms + {margin:.2}ms <= mosh avg {mosh_avg:.2}ms"); - } - if let Some(max_ms) = args.assert_dosh_max_ms { - let dosh_avg = avg_ms(&dosh_times); - if dosh_avg > max_ms { - return Err(anyhow!( - "{dosh_label} avg {dosh_avg:.2}ms exceeded max {max_ms:.2}ms" - )); - } - println!("gate ok: {dosh_label} avg {dosh_avg:.2}ms <= {max_ms:.2}ms"); + if args.json { + print_json(&args, &results); + } else { + print_table(&args, &results); } + + run_assertions(&args, &results)?; Ok(()) } +/// Paths explicitly requested via flags. +fn explicit_dosh_paths(args: &Args) -> Vec { + let mut paths = Vec::new(); + if args.cold_native { + paths.push(DoshPath::ColdNative); + } + if args.cached_ticket { + paths.push(DoshPath::CachedTicket); + } + if args.resume { + paths.push(DoshPath::Resume); + } + paths +} + +/// The single path implied by legacy flags when no explicit path is requested. +fn legacy_dosh_path(args: &Args) -> DoshPath { + if args.local_auth { + DoshPath::LocalAuth + } else if args.warm_cache { + DoshPath::CachedTicket + } else { + DoshPath::SshBootstrap + } +} + +/// True when none of the selected paths needs an SSH baseline comparison +/// (i.e. all are local-auth or cache fast paths driven without SSH). +fn dosh_only(paths: &[DoshPath]) -> bool { + paths.iter().all(|p| { + matches!( + p, + DoshPath::LocalAuth | DoshPath::CachedTicket | DoshPath::Resume + ) + }) +} + fn time_dosh_attach( client: &PathBuf, args: &Args, + path: DoshPath, control_path: Option<&PathBuf>, + warm: bool, ) -> Result { let mut cmd = Command::new(client); cmd.arg("--attach-only") @@ -178,30 +242,66 @@ fn time_dosh_attach( if let Some(host) = &args.dosh_host { cmd.arg("--dosh-host").arg(host); } - if args.local_auth { - cmd.arg("--local-auth").arg(&args.server); - } else { - cmd.arg("--ssh-port") - .arg(args.ssh_port.to_string()) - .arg("--ssh-auth-command") - .arg(&args.ssh_auth_command); - if args.no_cache { + + match path { + DoshPath::LocalAuth => { + cmd.arg("--local-auth"); + // While warming, write the cache; measured runs honor --no-cache. + if args.no_cache && !warm { + cmd.arg("--no-cache"); + } + cmd.arg(&args.server); + } + DoshPath::CachedTicket | DoshPath::Resume => { + // Fast paths must read the warmed cache, so never pass --no-cache here. + // Whether the cache uses tickets vs resume is decided by the client + // config (`cache_attach_tickets`) the harness wrote into HOME. + if args.local_auth { + cmd.arg("--local-auth"); + } + add_bootstrap_args(&mut cmd, args, control_path); + cmd.arg(&args.server); + } + DoshPath::ColdNative => { + cmd.arg("--auth").arg("native"); + // Cold path: never use a warmed cache. cmd.arg("--no-cache"); + add_bootstrap_args(&mut cmd, args, control_path); + cmd.arg(&args.server); } - if let Some(key) = &args.ssh_key { - cmd.arg("--ssh-key").arg(key); + DoshPath::SshBootstrap => { + add_bootstrap_args(&mut cmd, args, control_path); + // Cold SSH bootstrap honors --no-cache on measured runs. + if args.no_cache && !warm { + cmd.arg("--no-cache"); + } + cmd.arg(&args.server); } - if let Some(known_hosts) = &args.ssh_known_hosts { - cmd.arg("--ssh-known-hosts").arg(known_hosts); - } - if let Some(control_path) = control_path { - cmd.arg("--ssh-control-path").arg(control_path); - } - cmd.arg(&args.server); } time_command(&mut cmd) } +/// Append the SSH bootstrap arguments shared by the non-local paths. Native +/// auth reuses the same SSH key/known-hosts/port plumbing. +fn add_bootstrap_args(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) { + if args.local_auth { + return; + } + cmd.arg("--ssh-port") + .arg(args.ssh_port.to_string()) + .arg("--ssh-auth-command") + .arg(&args.ssh_auth_command); + if let Some(key) = &args.ssh_key { + cmd.arg("--ssh-key").arg(key); + } + if let Some(known_hosts) = &args.ssh_known_hosts { + cmd.arg("--ssh-known-hosts").arg(known_hosts); + } + if let Some(control_path) = control_path { + cmd.arg("--ssh-control-path").arg(control_path); + } +} + fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) { cmd.arg("-p").arg(args.ssh_port.to_string()).arg("-T"); if let Some(key) = &args.ssh_key { @@ -381,9 +481,194 @@ fn time_command(cmd: &mut Command) -> Result { Ok(start.elapsed()) } -fn avg_ms(samples: &[Duration]) -> f64 { - let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum(); - total / samples.len() as f64 +/// Per-metric samples with summary statistics. +struct MetricSamples { + name: &'static str, + samples: Vec, +} + +impl MetricSamples { + fn new(name: &'static str, samples: Vec) -> Self { + Self { name, samples } + } + + fn ms(&self) -> Vec { + self.samples + .iter() + .map(|d| d.as_secs_f64() * 1000.0) + .collect() + } + + fn stats(&self) -> Stats { + Stats::from_ms(&self.ms()) + } +} + +/// Summary statistics for a set of millisecond samples. +struct Stats { + count: usize, + min: f64, + median: f64, + p95: f64, + mean: f64, + max: f64, +} + +impl Stats { + fn from_ms(values: &[f64]) -> Self { + if values.is_empty() { + return Self { + count: 0, + min: 0.0, + median: 0.0, + p95: 0.0, + mean: 0.0, + max: 0.0, + }; + } + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let count = sorted.len(); + let mean = sorted.iter().sum::() / count as f64; + Self { + count, + min: sorted[0], + median: percentile(&sorted, 50.0), + p95: percentile(&sorted, 95.0), + mean, + max: sorted[count - 1], + } + } +} + +/// Linear-interpolated percentile over a pre-sorted slice. +fn percentile(sorted: &[f64], pct: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + if sorted.len() == 1 { + return sorted[0]; + } + let rank = (pct / 100.0) * (sorted.len() - 1) as f64; + let lo = rank.floor() as usize; + let hi = rank.ceil() as usize; + if lo == hi { + sorted[lo] + } else { + let frac = rank - lo as f64; + sorted[lo] + (sorted[hi] - sorted[lo]) * frac + } +} + +fn print_table(args: &Args, results: &[MetricSamples]) { + if let Some(label) = &args.label { + println!("# label: {label}"); + } + println!( + "{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}", + "metric", "n", "min", "median", "p95", "mean", "max" + ); + for metric in results { + let s = metric.stats(); + println!( + "{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}", + metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max + ); + } + // Raw per-iteration samples, so published numbers can include raw data. + for metric in results { + let ms: Vec = metric.ms().iter().map(|v| format!("{v:.2}")).collect(); + println!("{} samples_ms=[{}]", metric.name, ms.join(", ")); + } +} + +fn print_json(args: &Args, results: &[MetricSamples]) { + let mut entries = Vec::new(); + for metric in results { + let s = metric.stats(); + let samples: Vec = metric.ms().iter().map(|v| format!("{v:.4}")).collect(); + entries.push(format!( + "{{\"metric\":\"{}\",\"count\":{},\"min\":{:.4},\"median\":{:.4},\"p95\":{:.4},\"mean\":{:.4},\"max\":{:.4},\"samples_ms\":[{}]}}", + metric.name, + s.count, + s.min, + s.median, + s.p95, + s.mean, + s.max, + samples.join(",") + )); + } + let label = match &args.label { + Some(label) => format!("\"{}\"", label.replace('"', "\\\"")), + None => "null".to_string(), + }; + println!( + "{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}", + args.iterations.max(1), + entries.join(",") + ); +} + +fn metric_mean(results: &[MetricSamples], name: &str) -> Option { + results + .iter() + .find(|m| m.name == name) + .map(|m| m.stats().mean) +} + +/// The headline dosh metric for assertions: prefer cached, then resume, then +/// cold native, then ssh-bootstrap, then local-auth. +fn primary_dosh(results: &[MetricSamples]) -> Option<(&'static str, f64)> { + const ORDER: [&str; 5] = [ + "dosh_cached_attach_ms", + "dosh_resume_ms", + "dosh_cold_native_ms", + "dosh_attach_ms", + "dosh_local_attach_ms", + ]; + for name in ORDER { + if let Some(mean) = metric_mean(results, name) { + return Some((name, mean)); + } + } + None +} + +fn run_assertions(args: &Args, results: &[MetricSamples]) -> Result<()> { + if let Some(margin) = args.assert_ssh_plus_ms { + let ssh = metric_mean(results, "ssh_true_ms") + .ok_or_else(|| anyhow!("--assert-ssh-plus-ms requires non-local SSH benchmark"))?; + let (name, dosh) = + primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?; + if dosh > ssh + margin { + return Err(anyhow!( + "{name} avg {dosh:.2}ms exceeded ssh avg {ssh:.2}ms + {margin:.2}ms" + )); + } + println!("gate ok: {name} avg {dosh:.2}ms <= ssh avg {ssh:.2}ms + {margin:.2}ms"); + } + if let Some(margin) = args.assert_mosh_minus_ms { + let mosh = metric_mean(results, "mosh_start_true_ms") + .ok_or_else(|| anyhow!("--assert-mosh-minus-ms requires --include-mosh benchmark"))?; + let (name, dosh) = + primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?; + if dosh + margin > mosh { + return Err(anyhow!( + "{name} avg {dosh:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh:.2}ms" + )); + } + println!("gate ok: {name} avg {dosh:.2}ms + {margin:.2}ms <= mosh avg {mosh:.2}ms"); + } + if let Some(max_ms) = args.assert_dosh_max_ms { + let (name, dosh) = + primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?; + if dosh > max_ms { + return Err(anyhow!("{name} avg {dosh:.2}ms exceeded max {max_ms:.2}ms")); + } + println!("gate ok: {name} avg {dosh:.2}ms <= {max_ms:.2}ms"); + } + Ok(()) } fn default_client_path() -> PathBuf { From cdeba047bcf574dced019319ae3bd2c2930a30ad Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:41:42 -0400 Subject: [PATCH 5/7] Add mosh-grade predictive local echo Replace the basic printable-only predictor with a clean-room speculative local-echo engine modeled on the Mosh paper's prediction overlay design (no GPL code copied or translated; original Rust written from the algorithm). Model: - Per-line cell model with a local cursor; printable keystrokes echo immediately, dimmed (and underlined on a very slow link) via save/restore cursor so full-screen TUIs are never corrupted. - Backspace and in-line Left/Right arrow prediction (CSI and SS3). - Epoch grouping per keystroke burst; anything unmodelable (CR/LF, escapes, control bytes, wide chars, right margin, paste-sized bursts) ends the epoch safely rather than mispredicting. - Frame-sequenced confirmation: an authoritative server frame supersedes and erases the overlay (dosh has no client-side emulator, so it cannot do Mosh's per-cell content match; frame arrival is the confirmation). - Glitch detection: contradicting output (e.g. alternate-screen entry) discards the epoch. - SRTT-based display policy with hysteresis and a latency-spike override; modes off / experimental(adaptive, default) / always via new client config `predict_mode` and `DOSH_PREDICT_MODE` env override. Improvements over the prior predictor: in-line arrow motion, dim+flag styling, exact-width erase, paste guard, and faster (frame-arrival) confirmation. Prediction is display-only and never swallows input. Adds unit tests: printable run, confirmation-clears, glitch-discards, backspace, line-wrap budget, paste-skip, newline, inline arrows, and the experimental SRTT threshold gate. Co-Authored-By: Claude Opus 4.8 --- src/bin/dosh-client.rs | 685 ++++++++++++++++++++++++++++++++++++++--- src/config.rs | 9 + 2 files changed, 656 insertions(+), 38 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index f9526e4..1b12bea 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -2074,7 +2074,15 @@ async fn run_terminal( let mut resize_tick = tokio::time::interval(Duration::from_millis(250)); let mut last_size = size().unwrap_or((80, 24)); let mut frame_buffer = FrameBuffer::default(); - let mut predictor = Predictor::new(predict && cred.mode != "view-only" && !forward_only); + // Resolve the prediction display policy (off / experimental / always). An + // env var wins for ad-hoc tuning; otherwise the client config's + // `predict_mode` provides the persistent default. Predictions only run in a + // read-write interactive session. + let predict_mode = resolve_predict_mode(); + let mut predictor = Predictor::with_mode( + predict && cred.mode != "view-only" && !forward_only, + predict_mode, + ); let (forward_tx, mut forward_rx) = mpsc::channel::(1024); let _forward_keepalive = if local_forwards.is_empty() { Some(forward_tx.clone()) @@ -2894,84 +2902,521 @@ async fn send_stream_packet( Ok(()) } +// --------------------------------------------------------------------------- +// Predictive local echo (speculative echo). +// +// This is a clean-room Rust implementation written from the design described in +// the Mosh paper (Winstein & Balakrishnan, "Mosh: An Interactive Remote Shell +// for Mobile Clients", USENIX ATC 2012) and a conceptual reading of how a +// prediction overlay behaves. No Mosh source was copied or translated; the data +// structures, byte handling, confirmation strategy, and drawing here are +// original to dosh. +// +// Why dosh's approach differs from Mosh's: Mosh runs a full terminal emulator +// on the client and keeps a cell-accurate framebuffer, so it can confirm each +// predicted character by comparing the predicted cell to the actual cell the +// server produced. dosh's client has no client-side emulator -- it blits opaque +// server byte-frames straight to the real terminal. So dosh cannot do +// content-level confirmation. Instead it confirms predictions by *frame +// sequencing*: when the server emits a new authoritative frame (output_seq +// advances) that reflects the keystrokes we predicted, that frame is rendered +// and supersedes our overlay; we erase the speculative glyphs and let the +// server's bytes stand. To draw and erase safely without an emulator, dosh +// keeps a tiny model of just the current line near the cursor. +// +// Correctness over coverage: a visibly wrong/sticky prediction is worse than no +// prediction, so anything we cannot model with confidence (escape sequences, +// control chars other than backspace, multi-byte / wide chars, the right +// margin) ends the current epoch and stops further speculation until the next +// confirmation re-bases us against authoritative output. + +/// Display policy for predictions, mirroring Mosh's off / adaptive / always +/// design (we call the adaptive mode "experimental" to match Mosh's naming for +/// the SRTT-gated mode that only shows predictions on a laggy link). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PredictMode { + /// Never show predictions. + Off, + /// Show predictions only when the link is laggy (SRTT above a trigger). + Experimental, + /// Always show predictions whenever any are outstanding. + Always, +} + +impl PredictMode { + fn parse(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "off" | "never" | "false" | "no" => PredictMode::Off, + "always" => PredictMode::Always, + // "experimental" / "adaptive" / "auto" / anything else -> adaptive. + _ => PredictMode::Experimental, + } + } +} + +/// SRTT (in milliseconds) above which the experimental mode begins showing +/// predictions, and below which it stops. Hysteresis avoids flapping on a link +/// hovering around the threshold. On a faster-than-this link, native local echo +/// already feels instant, so speculation only adds risk. +const SRTT_TRIGGER_HIGH_MS: f64 = 30.0; +const SRTT_TRIGGER_LOW_MS: f64 = 20.0; + +/// SRTT (ms) above which we additionally underline ("flag") predicted cells so +/// the user can tell speculative glyphs from confirmed ones on a very slow link. +const FLAG_TRIGGER_HIGH_MS: f64 = 80.0; +const FLAG_TRIGGER_LOW_MS: f64 = 50.0; + +/// If a prediction is still outstanding after this long, treat the link as +/// laggy enough to force display even if the SRTT estimate hasn't caught up yet +/// (covers a sudden latency spike on the very first slow keystroke). +const GLITCH_FORCE_MS: u128 = 250; + +/// One speculatively-echoed character on the current line. +#[derive(Clone, Debug)] +struct PredictedCell { + /// The byte we drew (always a single printable ASCII byte, 0x20..=0x7e). + byte: u8, + /// Epoch this prediction belongs to (a keystroke burst). + epoch: u64, +} + +/// Predictive local-echo engine. See the module comment above for the model. struct Predictor { + mode: PredictMode, enabled: bool, + /// True while the server is in the alternate screen (a full-screen TUI such + /// as vim/htop); we never speculate there because we cannot model arbitrary + /// cursor addressing safely. alternate_screen: bool, - pending: Vec, + + /// Predicted cells for the current line, left-to-right starting at the + /// column where the cursor sat when this run of predictions began. + cells: Vec, + /// Cursor offset, in cells, from the start of `cells`. Equals `cells.len()` + /// while typing at the end of the line; can be less after Left-arrow or + /// backspace within the predicted span. + cursor: usize, + /// Current keystroke-burst epoch. Bumped whenever we hit something we cannot + /// model (CR/LF, escape, control byte, wide char, right margin) so those + /// speculative cells become "tentative" and stop being drawn. + epoch: u64, + /// Highest epoch confirmed by authoritative server output. Cells whose epoch + /// is greater than this are tentative and are not displayed. + confirmed_epoch: u64, + + /// How many columns the current overlay occupies on screen (0 = nothing + /// drawn). Used to erase exactly what was painted before re-rendering. + drawn_width: usize, + /// Whether the last draw underlined the cells (flagging on). + drawn_flagged: bool, + + // --- SRTT estimation (display-only; never gates whether input is sent) --- + /// Smoothed round-trip time estimate in milliseconds, or None until we have + /// a sample. + srtt_ms: Option, + /// When the oldest still-outstanding prediction was made. Used both to + /// sample SRTT on the next frame and to force display on a latency spike. + oldest_pending_at: Option, + /// Hysteresis latches. + srtt_trigger: bool, + flagging: bool, } impl Predictor { + /// Construct with the default adaptive (experimental) display policy. + #[cfg(test)] fn new(enabled: bool) -> Self { + Self::with_mode(enabled, PredictMode::Experimental) + } + + fn with_mode(enabled: bool, mode: PredictMode) -> Self { Self { - enabled, + mode, + enabled: enabled && mode != PredictMode::Off, alternate_screen: false, - pending: Vec::new(), + cells: Vec::new(), + cursor: 0, + epoch: 1, + confirmed_epoch: 0, + drawn_width: 0, + drawn_flagged: false, + srtt_ms: None, + oldest_pending_at: None, + srtt_trigger: false, + flagging: false, } } + /// Drop all speculative state. Called on reconnect/resume and screen resize, + /// where any cached line model would be stale. fn reset(&mut self) { - self.pending.clear(); + let _ = self.erase_drawn(); + self.cells.clear(); + self.cursor = 0; + self.epoch += 1; + self.confirmed_epoch = self.epoch - 1; self.alternate_screen = false; + self.oldest_pending_at = None; } + /// Number of speculative cells currently outstanding (active in any epoch). + #[cfg(test)] + fn pending_len(&self) -> usize { + self.cells.len() + } + + /// End the current keystroke burst (Mosh's "become tentative"). In dosh's + /// frame-confirmation model we cannot content-verify lingering cells, so the + /// safe choice is to drop the current epoch's speculative cells outright and + /// start a fresh epoch. This is how we bail out on anything we cannot model + /// (escape sequences, CR/LF, control bytes, wide chars, the right margin) + /// without risking a stale/wrong glyph. + fn become_tentative(&mut self) { + let _ = self.erase_drawn(); + self.cells.clear(); + self.cursor = 0; + self.epoch += 1; + self.oldest_pending_at = None; + } + + /// Observe raw input bytes that are *also* being sent to the server. This is + /// display-only and never consumes input. The caller still forwards `bytes` + /// unconditionally. fn observe_input(&mut self, bytes: &[u8]) -> Result<()> { if !self.enabled || self.alternate_screen { return Ok(()); } - if !Self::predictable(bytes) { - self.clear_pending()?; - return Ok(()); + // A large burst is almost certainly a paste, not interactive typing. + // Predicting it would risk wrapping a long dim run across many lines, so + // we bail: drop any overlay and let the authoritative frame draw it. + if bytes.len() > PREDICT_BURST_LIMIT { + self.become_tentative(); + return self.redraw(); } - self.pending.extend_from_slice(bytes); - self.draw_pending() + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + match b { + // Printable ASCII: append a predicted cell at the cursor. + 0x20..=0x7e => { + self.predict_char(b); + i += 1; + } + // Backspace (^H) or DEL (^?): remove the previous predicted cell. + 0x08 | 0x7f => { + self.predict_backspace(); + i += 1; + } + // ESC: try to recognize a cursor-motion CSI/SS3 we can model + // (Left/Right within the line); otherwise become tentative. + 0x1b => { + let consumed = self.predict_escape(&bytes[i..]); + if consumed == 0 { + // Unrecognized / incomplete escape: bail safely. + self.become_tentative(); + i = bytes.len(); + } else { + i += consumed; + } + } + // CR / LF and any other control byte: we cannot model where the + // cursor lands (shell prompt, scroll, etc.), so end the epoch. + _ => { + self.become_tentative(); + i += 1; + } + } + } + if self.oldest_pending_at.is_none() && !self.cells.is_empty() { + self.oldest_pending_at = Some(Instant::now()); + } + self.redraw() } + /// Predict one printable character at the current cursor position. + fn predict_char(&mut self, byte: u8) { + // Right margin is tricky (shells, editors, and wrap behavior differ), so + // we refuse to predict past a conservative column budget and bail out + // instead of risking a wrong glyph at a wrap point. + if self.cells.len() >= PREDICT_MAX_LINE { + self.become_tentative(); + return; + } + let cell = PredictedCell { + byte, + epoch: self.epoch, + }; + if self.cursor < self.cells.len() { + // Typing over an existing predicted cell (after a Left arrow): + // overwrite in place, as a terminal in insert-off mode would. + self.cells[self.cursor] = cell; + } else { + self.cells.push(cell); + } + self.cursor += 1; + } + + /// Predict a backspace: erase the previous predicted cell if we have one. + fn predict_backspace(&mut self) { + if self.cursor == 0 { + // We'd be backspacing past the start of our predicted span into + // territory we don't model -> bail safely. + self.become_tentative(); + return; + } + self.cursor -= 1; + // Only safe to shrink the line if we're at its end; otherwise a + // mid-line backspace shifts everything left, which we don't model, so + // bail. + if self.cursor == self.cells.len() - 1 { + self.cells.pop(); + } else { + self.become_tentative(); + } + } + + /// Recognize Left/Right cursor motion escape sequences we can model and + /// apply them to the local cursor. Returns the number of bytes consumed, or + /// 0 if `data` does not start with a sequence we handle. + /// + /// Improvement over the previous dosh predictor (which bailed on any escape) + /// and parity with Mosh: predicting in-line arrow-key motion so cursor + /// repositioning feels instant too. + fn predict_escape(&mut self, data: &[u8]) -> usize { + // Both CSI (ESC [) and application-cursor SS3 (ESC O) are used for arrows. + if data.len() < 3 || data[0] != 0x1b { + return 0; + } + if data[1] != b'[' && data[1] != b'O' { + return 0; + } + match data[2] { + // Right arrow: only within already-predicted cells. + b'C' if self.cursor < self.cells.len() => { + self.cursor += 1; + 3 + } + // Left arrow. + b'D' if self.cursor > 0 => { + self.cursor -= 1; + 3 + } + // Recognized arrow but at the edge of our predicted span, or + // Up/Down/Home/End/etc. which cross lines or jump unpredictably: + // refuse so the caller becomes tentative rather than mispredicting. + _ => 0, + } + } + + /// Observe authoritative server output. Detects alternate-screen transitions + /// and confirms outstanding predictions: a fresh frame means the server has + /// re-rendered the line, so our speculative glyphs are superseded and erased. fn observe_output(&mut self, bytes: &[u8]) { if contains_bytes(bytes, b"\x1b[?1049h") || contains_bytes(bytes, b"\x1b[?1047h") || contains_bytes(bytes, b"\x1b[?47h") { self.alternate_screen = true; - self.pending.clear(); + let _ = self.discard_all(); } if contains_bytes(bytes, b"\x1b[?1049l") || contains_bytes(bytes, b"\x1b[?1047l") || contains_bytes(bytes, b"\x1b[?47l") { self.alternate_screen = false; - self.pending.clear(); + let _ = self.discard_all(); } } + /// Sample SRTT from a newly arrived frame and confirm/clear outstanding + /// predictions. Called once per accepted authoritative frame, *before* the + /// frame's bytes are written to the terminal so the server render lands on a + /// clean line. Returns nothing; drawing is handled by the caller's render + /// followed by `redraw` on the next input. + fn confirm_with_frame(&mut self) -> Result<()> { + if let Some(sent_at) = self.oldest_pending_at.take() { + let sample = sent_at.elapsed().as_secs_f64() * 1000.0; + self.update_srtt(sample); + } + // The frame is authoritative for the line; clear our overlay so the + // server's bytes are the only thing on screen. This is dosh's + // confirmation: frame arrival == the predicted keystrokes are now echoed + // by the server. Faster than waiting for per-cell content match. + self.discard_all() + } + + fn update_srtt(&mut self, sample_ms: f64) { + // Standard exponentially weighted moving average (RFC 6298 style alpha). + const ALPHA: f64 = 0.125; + self.srtt_ms = Some(match self.srtt_ms { + None => sample_ms, + Some(prev) => (1.0 - ALPHA) * prev + ALPHA * sample_ms, + }); + } + + /// Test seam: pin the SRTT estimate and refresh the display triggers so the + /// experimental-mode threshold behavior can be exercised deterministically. + #[cfg(test)] + fn set_srtt_for_test(&mut self, ms: f64) { + self.srtt_ms = Some(ms); + self.update_triggers(); + } + + /// Whether we should *display* predictions right now under the active policy. + fn should_display(&self) -> bool { + match self.mode { + PredictMode::Off => false, + PredictMode::Always => true, + PredictMode::Experimental => { + if self.srtt_trigger { + return true; + } + // Force display on a latency spike even before SRTT catches up. + if let Some(at) = self.oldest_pending_at + && at.elapsed().as_millis() >= GLITCH_FORCE_MS + { + return true; + } + false + } + } + } + + /// Update the SRTT/flag hysteresis latches from the current estimate. + fn update_triggers(&mut self) { + let srtt = self.srtt_ms.unwrap_or(0.0); + if srtt > SRTT_TRIGGER_HIGH_MS { + self.srtt_trigger = true; + } else if srtt <= SRTT_TRIGGER_LOW_MS { + self.srtt_trigger = false; + } + if srtt > FLAG_TRIGGER_HIGH_MS { + self.flagging = true; + } else if srtt <= FLAG_TRIGGER_LOW_MS { + self.flagging = false; + } + } + + /// The active (displayable) predicted bytes: cells in confirmed-or-current + /// epochs only, drawn from the start of the line up to the cursor budget. + fn visible_bytes(&self) -> Vec { + self.cells + .iter() + .filter(|c| c.epoch > self.confirmed_epoch || c.epoch == self.epoch) + .map(|c| c.byte) + .collect() + } + + /// Repaint the prediction overlay: erase the old one, draw the new one if + /// policy says so. Uses save/restore cursor so it never moves the real + /// cursor and, when nothing should show, leaves the terminal untouched. + fn redraw(&mut self) -> Result<()> { + self.update_triggers(); + // Always erase whatever we drew before so we never leave a stale glyph. + self.erase_drawn()?; + if !self.should_display() { + return Ok(()); + } + let bytes = self.visible_bytes(); + if bytes.is_empty() { + return Ok(()); + } + let flag = self.flagging; + // Save cursor, dim (and optionally underline) predicted glyphs, draw, + // reset attributes, restore cursor. + let mut out = Vec::with_capacity(bytes.len() + 12); + out.extend_from_slice(b"\x1b7"); + out.extend_from_slice(if flag { b"\x1b[2;4m" } else { b"\x1b[2m" }); + out.extend_from_slice(&bytes); + out.extend_from_slice(b"\x1b[0m\x1b8"); + emit_overlay(&out)?; + // Record exactly how many columns we painted so erasure overwrites the + // right amount even if the model changes before the next repaint. + self.drawn_width = bytes.len(); + self.drawn_flagged = flag; + let _ = self.drawn_flagged; + Ok(()) + } + + /// Erase the currently drawn overlay (if any) by overwriting the previously + /// painted columns with spaces, using save/restore cursor so the real cursor + /// and surrounding text are untouched. + fn erase_drawn(&mut self) -> Result<()> { + let width = self.drawn_width; + self.drawn_width = 0; + if width == 0 { + return Ok(()); + } + let mut out = Vec::with_capacity(width + 4); + out.extend_from_slice(b"\x1b7"); + out.resize(out.len() + width, b' '); + out.extend_from_slice(b"\x1b8"); + emit_overlay(&out) + } + + /// Erase the overlay and drop all speculative state, advancing the confirmed + /// epoch so nothing lingers. + fn discard_all(&mut self) -> Result<()> { + self.erase_drawn()?; + self.cells.clear(); + self.cursor = 0; + self.epoch += 1; + self.confirmed_epoch = self.epoch - 1; + self.oldest_pending_at = None; + Ok(()) + } + + /// Erase the overlay without dropping the SRTT estimate or model; used right + /// before an authoritative frame is rendered so its bytes land cleanly. + /// (Kept as the public name the run loop calls.) + fn clear_pending(&mut self) -> Result<()> { + self.confirm_with_frame() + } + + /// Whether a byte sequence is a plain printable run. Retained as a small + /// predicate exercised by the unit tests. + #[cfg(test)] fn predictable(bytes: &[u8]) -> bool { !bytes.is_empty() && bytes.iter().all(|byte| matches!(byte, 0x20..=0x7e)) } +} - fn draw_pending(&self) -> Result<()> { - if self.pending.is_empty() { - return Ok(()); - } - let mut stdout = std::io::stdout(); - stdout.write_all(b"\x1b7\x1b[4m")?; - stdout.write_all(&self.pending)?; - stdout.write_all(b"\x1b[24m\x1b8")?; - stdout.flush()?; - Ok(()) +/// Conservative cap on how many predicted cells we keep on one line before we +/// stop speculating (a backstop against runaway predictions; the right-margin / +/// wrap point is genuinely ambiguous without a client-side emulator). +const PREDICT_MAX_LINE: usize = 256; + +/// Largest single input burst we treat as interactive typing. Anything larger +/// is assumed to be a paste and is not speculated (would risk a long dim run +/// wrapping across lines before the server frame supersedes it). +const PREDICT_BURST_LIMIT: usize = 32; + +/// Write a prepared overlay byte sequence to the real terminal. Suppressed +/// under `cfg(test)` so unit tests exercise the prediction model without +/// emitting escape sequences to the test runner's terminal. +#[cfg(not(test))] +fn emit_overlay(bytes: &[u8]) -> Result<()> { + let mut stdout = std::io::stdout(); + stdout.write_all(bytes)?; + stdout.flush()?; + Ok(()) +} + +#[cfg(test)] +fn emit_overlay(_bytes: &[u8]) -> Result<()> { + Ok(()) +} + +/// Resolve the prediction display policy. `DOSH_PREDICT_MODE` (off / experimental +/// / always) overrides for quick experiments; otherwise the client config's +/// `predict_mode` is used, defaulting to the adaptive (experimental) policy. +fn resolve_predict_mode() -> PredictMode { + if let Ok(env) = std::env::var("DOSH_PREDICT_MODE") { + return PredictMode::parse(&env); } - - fn clear_pending(&mut self) -> Result<()> { - if self.pending.is_empty() { - return Ok(()); - } - let mut stdout = std::io::stdout(); - stdout.write_all(b"\x1b7")?; - for _ in 0..self.pending.len() { - stdout.write_all(b" ")?; - } - stdout.write_all(b"\x1b8")?; - stdout.flush()?; - self.pending.clear(); - Ok(()) + match load_client_config(None) { + Ok(cfg) => PredictMode::parse(&cfg.predict_mode), + Err(_) => PredictMode::Experimental, } } @@ -3137,8 +3582,8 @@ const TERMINAL_CLEANUP: &[u8] = concat!( #[cfg(test)] mod tests { use super::{ - DynamicForward, FrameBuffer, LocalForward, Predictor, RemoteForward, SshConfig, - auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward, + DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor, RemoteForward, + SshConfig, auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until, requested_env, ssh_destination_host, ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted, @@ -3514,6 +3959,170 @@ mod tests { assert!(!predictor.alternate_screen); } + /// Typing printable characters builds an in-order predicted line and the + /// local cursor tracks the end of it. + #[test] + fn predictor_predicts_printable_run() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + p.observe_input(b"hi").unwrap(); + assert_eq!(p.visible_bytes(), b"hi"); + assert_eq!(p.cursor, 2); + assert_eq!(p.pending_len(), 2); + } + + /// An authoritative server frame confirms (and clears) outstanding + /// predictions: after a frame the overlay is empty. + #[test] + fn predictor_confirmation_clears_prediction() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + p.observe_input(b"abc").unwrap(); + assert_eq!(p.pending_len(), 3); + + // Server echoes the keystrokes; a new frame arrives. + p.clear_pending().unwrap(); // run loop calls this before render_frame + assert_eq!(p.pending_len(), 0); + assert!(p.visible_bytes().is_empty()); + } + + /// A glitch -- server output that contradicts the prediction (here the + /// server enters the alternate screen mid-burst) -- discards the epoch's + /// predictions so nothing wrong is left on screen. + #[test] + fn predictor_glitch_discards_epoch() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + p.observe_input(b"vim").unwrap(); + assert_eq!(p.pending_len(), 3); + let epoch_before = p.epoch; + + // Server diverges: enters a full-screen TUI. Our line prediction is now + // meaningless and must be dropped. + p.observe_output(b"\x1b[?1049h"); + assert!(p.alternate_screen); + assert_eq!(p.pending_len(), 0); + // The contradicted epoch is fully retired (confirmed past). + assert!(p.confirmed_epoch >= epoch_before); + + // While in the alternate screen we refuse to speculate at all. + p.observe_input(b"x").unwrap(); + assert_eq!(p.pending_len(), 0); + } + + /// Backspace prediction removes the last predicted cell from the end of the + /// line and moves the local cursor back. + #[test] + fn predictor_predicts_backspace() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + p.observe_input(b"hello").unwrap(); + assert_eq!(p.visible_bytes(), b"hello"); + + p.observe_input(b"\x7f").unwrap(); // DEL + assert_eq!(p.visible_bytes(), b"hell"); + assert_eq!(p.cursor, 4); + + p.observe_input(&[0x08]).unwrap(); // ^H + assert_eq!(p.visible_bytes(), b"hel"); + assert_eq!(p.cursor, 3); + + // Backspacing past the start of our predicted span bails safely (we + // don't model what's to the left), ending the epoch with no glyphs. + p.observe_input(b"\x7f\x7f\x7f\x7f").unwrap(); + assert_eq!(p.pending_len(), 0); + } + + /// Right-margin / line-wrap edge: we refuse to predict past a conservative + /// per-line budget instead of risking a wrong glyph at the wrap point. + /// Bytes are fed in small interactive bursts so the per-line budget (not the + /// paste guard) is what trips. + #[test] + fn predictor_line_wrap_edge_bails_at_budget() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + let chunk = vec![b'x'; super::PREDICT_BURST_LIMIT]; + let mut typed = 0; + while typed + chunk.len() <= super::PREDICT_MAX_LINE { + p.observe_input(&chunk).unwrap(); + typed += chunk.len(); + } + assert_eq!(p.pending_len(), super::PREDICT_MAX_LINE); + + // One more character crosses the budget -> bail, drop the epoch. + p.observe_input(b"y").unwrap(); + assert_eq!(p.pending_len(), 0); + } + + /// A paste-sized burst is not speculated at all (no overlay), but the input + /// is still forwarded by the caller -- prediction is display-only. + #[test] + fn predictor_skips_paste_sized_burst() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + let paste = vec![b'a'; super::PREDICT_BURST_LIMIT + 1]; + p.observe_input(&paste).unwrap(); + assert_eq!(p.pending_len(), 0); + } + + /// Carriage return / newline cannot be modeled (prompt, scroll), so it ends + /// the epoch and clears the speculative line. + #[test] + fn predictor_newline_ends_epoch() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + p.observe_input(b"ls").unwrap(); + assert_eq!(p.pending_len(), 2); + p.observe_input(b"\r").unwrap(); + assert_eq!(p.pending_len(), 0); + } + + /// In-line arrow-key motion (improvement over the old printable-only + /// predictor): Left/Right move the local cursor within the predicted span + /// and a following keystroke overwrites in place. + #[test] + fn predictor_predicts_inline_arrows() { + let mut p = Predictor::with_mode(true, PredictMode::Always); + p.observe_input(b"abc").unwrap(); + assert_eq!(p.cursor, 3); + p.observe_input(b"\x1b[D").unwrap(); // left + assert_eq!(p.cursor, 2); + p.observe_input(b"\x1bOD").unwrap(); // left (application/SS3) + assert_eq!(p.cursor, 1); + p.observe_input(b"\x1b[C").unwrap(); // right + assert_eq!(p.cursor, 2); + // Overwrite the cell at the cursor (index 2, the 'c') in place. + p.observe_input(b"Z").unwrap(); + assert_eq!(p.visible_bytes(), b"abZ"); + assert_eq!(p.cursor, 3); + } + + /// Experimental (adaptive) mode shows no prediction while SRTT is below the + /// trigger, and shows it once SRTT rises above the trigger. + #[test] + fn predictor_experimental_respects_srtt_threshold() { + let mut p = Predictor::with_mode(true, PredictMode::Experimental); + // Fast link: no fresh prediction yet, no SRTT sample -> not displayed. + p.set_srtt_for_test(5.0); + p.observe_input(b"abc").unwrap(); + assert!(p.pending_len() > 0); // model still tracks the prediction... + assert!(!p.should_display()); // ...but policy hides it on a fast link. + + // Slow link: SRTT above the high trigger -> predictions are displayed. + p.set_srtt_for_test(60.0); + assert!(p.should_display()); + } + + /// Always mode displays regardless of SRTT; Off never does. + #[test] + fn predictor_mode_policies() { + let mut always = Predictor::with_mode(true, PredictMode::Always); + always.observe_input(b"x").unwrap(); + assert!(always.should_display()); + + let off = Predictor::with_mode(true, PredictMode::Off); + assert!(!off.enabled); + assert!(!off.should_display()); + + assert_eq!(PredictMode::parse("off"), PredictMode::Off); + assert_eq!(PredictMode::parse("ALWAYS"), PredictMode::Always); + assert_eq!(PredictMode::parse("adaptive"), PredictMode::Experimental); + assert_eq!(PredictMode::parse("anything"), PredictMode::Experimental); + } + #[test] fn startup_command_joins_args_for_remote_shell() { assert_eq!(startup_command(&[]), None); diff --git a/src/config.rs b/src/config.rs index 0fb0f45..4e26a35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -84,6 +84,10 @@ pub struct ClientConfig { pub view_only: bool, #[serde(default)] pub predict: bool, + /// Prediction display policy: "off", "experimental" (adaptive, the default), + /// or "always". Controls when speculative local echo is shown. + #[serde(default = "default_predict_mode")] + pub predict_mode: String, pub cache_attach_tickets: bool, pub credential_cache: String, #[serde(default = "default_auth_preference")] @@ -141,6 +145,7 @@ impl Default for ClientConfig { reconnect_timeout_secs: 5, view_only: false, predict: false, + predict_mode: default_predict_mode(), cache_attach_tickets: true, credential_cache: "~/.local/share/dosh/credentials".to_string(), auth_preference: default_auth_preference(), @@ -183,6 +188,10 @@ fn default_native_auth_timeout_ms() -> u64 { 700 } +fn default_predict_mode() -> String { + "experimental".to_string() +} + fn default_known_hosts() -> String { "~/.config/dosh/known_hosts".to_string() } From 1e5517dcf1aa586aa21cfd12142d055b50589ee6 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:42:03 -0400 Subject: [PATCH 6/7] Add llms.txt agent-oriented overview Self-contained orientation for AI agents: what dosh is, capabilities, the bidirectional homelab forwarding patterns (-L/-R/-D with concrete examples), fast-path order, architecture, security summary, config, and how to drive dosh non-interactively. Links to the deeper docs. Co-Authored-By: Claude Opus 4.8 --- llms.txt | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 llms.txt diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..e5e6558 --- /dev/null +++ b/llms.txt @@ -0,0 +1,201 @@ +# dosh (Dormant Shell) + +> dosh is a low-latency remote terminal for homelab/personal servers. It is mosh-shaped +> but not a mosh clone: `dosh-server` is a resident daemon that keeps terminal sessions +> hot, and the client reconnects over encrypted UDP — so attach and reconnect are +> near-instant (~3 ms of local overhead + one network RTT). SSH is used once to +> establish trust; after that, repeat attaches skip SSH entirely. It also does SSH-style +> TCP port forwarding (`-L`/`-R`/`-D`) over the same encrypted transport, which is what +> makes back-and-forth client↔server homelab comms easy. + +This file orients an AI agent (or a human) on what dosh is, what it can do, and how to +drive it. It is intentionally self-contained. For deeper detail, see the linked docs at +the bottom. + +## What dosh is for + +Use dosh instead of `ssh`/`mosh` for **interactive shells and TCP forwarding** to a +server where you control both ends and have installed `dosh-server` (typically a homelab +box, VPS, or workstation). It shines when you: + +- want a terminal that survives laptop sleep, Wi-Fi changes, and NAT rebinding, and + resumes instantly instead of hanging; +- reconnect to the same box many times a day and don't want to pay SSH startup each time; +- need to reach services on the server from your laptop (or vice-versa) without standing + up a VPN. + +dosh is **not** a drop-in for every SSH use. It does not do `scp`/`sftp` file transfer, +X11, or act as an `sshd` for arbitrary SSH clients. Keep `ssh` installed for those. + +## Core capabilities + +- **Encrypted UDP terminal transport** — AEAD-encrypted, with packet sequencing, a + sliding replay window, ACKs, and server-side retransmit of unacked output. +- **Resident server + hot sessions** — `dosh-server` runs as a daemon; named sessions + (and a prewarmed `default`) stay alive across client disconnects. +- **Fast attach / reconnect** — see "Fast path order" below; cached attach is ~one RTT. +- **Roaming** — the session follows the client across IP/port changes. +- **Named & shared sessions** — reattach the same persistent terminal from multiple + clients; optional **view-only** clients. +- **TCP port forwarding** — local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) over + the encrypted transport, with per-stream flow control and terminal-priority + scheduling (bulk transfers don't lag your keystrokes). +- **Native UDP auth** — Ed25519 user auth via ssh-agent or an (optionally encrypted) + OpenSSH key, verified against `authorized_keys`. Falls back to SSH bootstrap when + native auth isn't available. SSH host config (`HostName`, `User`, `Port`, + `IdentityFile`, `ProxyJump`, etc.) is honored. +- **Host-key pinning** — TOFU/known-hosts with hard-fail on mismatch. +- **Speculative local echo** — optional predictive echo for laggy links (display-only; + real input is always sent to the server). +- **Ops commands** — `doctor`, `sessions`, `trust`, `import-ssh`, `update`. + +## Quickstart + +Install the server on each box you want to reach (default UDP port `50000`): + +```bash +curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ + | DOSH_PORT=50000 sh -s -- server +``` + +Install the client (macOS/Linux), then attach: + +```bash +curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ + | DOSH_SERVER=homelab DOSH_HOST=homelab.example.com DOSH_PORT=50000 sh -s -- client + +dosh homelab # fresh interactive shell +dosh homelab uptime # run one command +dosh --session work homelab # named, persistent, reattachable session +``` + +- Detach (leave the server session running): **Ctrl-]**. +- End the session: type `exit` in the remote shell. +- If UDP stalls, dosh keeps the terminal open, sends keepalives, and ticket-reconnects. + +## Client↔server homelab comms (the back-and-forth) + +Forwarding is the key to "make homelab comms easy." Three directions: + +| Command | Direction | Effect | +| --- | --- | --- | +| `dosh -L [bind:]LPORT:THOST:TPORT host` | pull server→you | A listener on **your machine** (`bind`, default localhost) forwards to `THOST:TPORT` reached **from the server**. | +| `dosh -R [bind:]LPORT:THOST:TPORT host` | push you→server | A listener on **the server** (loopback by default) forwards to `THOST:TPORT` reached **from your machine**. | +| `dosh -D [bind:]LPORT host` | SOCKS via server | A SOCKS5 proxy on **your machine**; traffic egresses **from the server**. | + +Concrete homelab patterns: + +```bash +# Reach the homelab's internal Grafana (server-side :3000) from your laptop browser: +dosh -L 3000:127.0.0.1:3000 homelab # open http://localhost:3000 + +# Reach a DB that only listens on the homelab LAN: +dosh -L 5432:10.0.0.5:5432 homelab # psql -h 127.0.0.1 -p 5432 + +# Let the homelab hit a dev server running on your laptop (e.g. a webhook target): +dosh -R 9000:127.0.0.1:8080 homelab # homelab curls http://127.0.0.1:9000 + +# Route browser traffic out through the homelab's network: +dosh -D 1080 homelab # SOCKS5 proxy at 127.0.0.1:1080 + +# Forward-only, no shell; multiple forwards; background after listeners are up: +dosh -N -L 3000:127.0.0.1:3000 -L 5432:10.0.0.5:5432 homelab +dosh -f -N -L 3000:127.0.0.1:3000 homelab +``` + +Server policy controls forwarding: `allow_tcp_forwarding`, `allow_remote_forwarding`, +`allow_remote_non_loopback_bind`, and per-key `permitopen=`/`no-port-forwarding` in +`authorized_keys`. Remote listeners bind to loopback unless explicitly allowed. + +## Fast path order (why it's quick) + +The client tries the cheapest valid path first: + +1. **UDP resume** — existing client id + session key; one encrypted UDP round-trip. +2. **UDP attach ticket** — cached server-issued ticket; one round-trip, no SSH. +3. **Native UDP auth** — Ed25519 handshake (ssh-agent/key) when enabled. +4. **SSH bootstrap** — `ssh user@host dosh-auth …` once, then a UDP attach. + +Measured locally (loopback, release): cached attach ≈ **3 ms**, cold native auth ≈ 9 ms. +Over a real link, add one RTT. See `docs/BENCHMARKS.md`. + +## Architecture (1-minute model) + +- **dosh-server** — single UDP socket on one port; a session table keyed by name; one + PTY per named session; per-session terminal screen state (vt100) for snapshots/diffs; + a per-session client table; encrypted UDP protocol; a small SSH-invoked `dosh-auth` + helper. Abandoned (clientless, non-prewarmed) sessions and their shells are reaped + after a grace period; prewarmed sessions stay hot. +- **dosh-client** — raw-mode terminal; local credential cache; tries resume/ticket/native + before SSH; forwards PTY I/O; reconnect/roaming state machine; optional predictive echo. +- **Binaries** — `dosh-server`, `dosh-client` (symlinked as `dosh`), `dosh-auth` + (SSH-invoked trust helper), `dosh-bench` (benchmarks). + +## Security model (summary) + +- First trust via SSH; thereafter encrypted Dosh transport. Native auth available. +- KEX X25519; AEAD ChaCha20-Poly1305; KDF HKDF-SHA256; host & user auth Ed25519; + SHA-256 transcript binding. Per-direction, per-sequence nonces; replay window. +- Host keys pinned in `~/.config/dosh/known_hosts`; mismatch hard-fails. +- User auth against `~/.ssh/authorized_keys` (+ optional `~/.config/dosh/authorized_keys`); + removed keys can't authenticate; unsupported restrictive options fail closed. +- Forward secrecy from ephemeral X25519; attach tickets are server-sealed and scoped. + +dosh claims security **equivalent to, and in places stronger than, SSH for the dosh +terminal/forwarding use case** — not full SSH-protocol parity. Read `docs/THREAT_MODEL.md` +for the honest stance, residual risks, and what's still pending before public claims. + +## Configuration + +- **Client** `~/.config/dosh/client.toml` — `auth_preference` (e.g. `"native,ssh"`), + `trust_on_first_use`, `identity_files`, `use_ssh_agent`, `forward_agent`, `send_env`, + `set_env`, `dosh_port`, `default_session`, `predict`, `reconnect_timeout_secs`, + `credential_cache`, `known_hosts`. +- **Hosts** `~/.config/dosh/hosts.toml` — per-alias `[name]` with `ssh`, `dosh_host`, + `port`, `user`, `default_command`, `predict`. Generate from SSH aliases with + `dosh import-ssh `. +- **Server** `~/.config/dosh/server.toml` — `port`, `bind`, `shell`, `prewarm_sessions`, + `native_auth`, `host_key`, `authorized_keys`, `attach_ticket_ttl_secs`, + `client_timeout_secs`, `allow_tcp_forwarding`, `allow_remote_forwarding`, + `allow_remote_non_loopback_bind`, `allow_agent_forwarding`, `accept_env`, + `native_auth_rate_limit_per_minute`. + +Paths: host key `~/.config/dosh/host_key`; credential cache +`~/.local/share/dosh/credentials/`. + +## Operating & diagnostics + +```bash +dosh doctor homelab # host resolution, trust state, UDP reachability, server + # version, usable keys, auth result, forwarding policy +dosh sessions homelab # list live sessions +dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback) +dosh trust --remove homelab +dosh import-ssh palav homelab # write a hosts.toml entry from an SSH alias +dosh update # update the installed client +``` + +If something fails, `dosh doctor ` is the first stop — every public error is meant +to be actionable (host trust, auth failure, UDP blocked, forwarding denied, version +mismatch, server unavailable). + +## For agents driving dosh + +- Run one command and exit: `dosh `. Render one frame and detach: + `dosh --attach-only `. Both are non-interactive-friendly. +- A real terminal size is needed for full-screen rendering; with no TTY the client falls + back to 80×24. Pass `-v`/`-vv` for timing/diagnostic logs on stderr. +- Prefer `--session ` to reattach a known session; bare `dosh ` opens a + fresh, uniquely-named session each time. +- Never assume file transfer or X11 — use `ssh`/`scp` for those. +- Diagnostics are scriptable via `dosh doctor `. + +## Reference docs + +- `README.md` — overview, install, develop, performance rules. +- `docs/NATIVE_V1_SPEC.md` — the native auth + forwarding v1 contract and verification + checklist (with current status). +- `docs/THREAT_MODEL.md` — security claims, threat model, residual risks. +- `docs/PUBLIC_READINESS.md` — feature matrix and §16 verification status. +- `docs/BENCHMARKS.md` — how to benchmark; metric definitions; sample numbers. +- `SPEC.md` — protocol/wire details. From f9c1973c1364e1414431ecf0754c8ddf96b4ade6 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:45:44 -0400 Subject: [PATCH 7/7] Harden native protocol: version discipline, rekey, migration, rate limit, O(1) client index Track A protocol hardening for native-v1. 1. Protocol VERSION discipline (protocol.rs, native.rs, dosh-server.rs, dosh-client.rs): bump protocol::VERSION to 2 and NATIVE_PROTOCOL_VERSION to 2. Foreign wire-version datagrams now get a clear named "protocol version mismatch - upgrade dosh" reject from the server instead of a silent timeout (peek_foreign_wire_version + VERSION_MISMATCH_REASON). The native handshake's plaintext protocol_version is also checked server-side before any crypto via check_native_protocol_version, returning a typed ProtocolVersionMismatch. 2. Transport rekey (spec section 11/9): server rotates a client's traffic key after rekey_after_packets OR rekey_after_secs (config knobs). Rotated keys are derived independently of the handshake keys from the current key plus fresh server CSPRNG material shipped confidentially in an AEAD Rekey packet (derive_rekey_session_key). The previous epoch's key is retained briefly so in-flight pre-rekey packets still decrypt (matched by session_key_id), and stale-epoch packets are ignored, not fatal. Client handles Rekey/RekeyAck. 3. Connection migration (spec section 11): every authenticated, replay-accepted packet now migrates client.endpoint to the new source address (input, resize, ping, ack, resume, stream*, rekey-ack), not just resume. Ping now verifies its AEAD tag before acting so migration cannot be spoofed. 4. Native auth rate limiting: per-source-IP token bucket (native_auth_rate_limit_per_minute) enforced in handle_native_client_hello BEFORE any X25519/Ed25519 work; over-limit hellos get a non-crypto reject. 5. Speed: replaced O(sessions x clients) per-packet linear scans with an O(1) HashMap index in ServerState, kept in sync on every client insert/remove (attach handlers, detach, remove_client, cleanup reap, session-exit drain). New config keys (ServerConfig, with defaults): rekey_after_packets = 100000, rekey_after_secs = 3600. Tests: version-mismatch reject (no hang), rate-limit flood reject, rekey round-trip end-to-end + key-derivation unit tests, source-address migration, client-index sync + cleanup purge. fmt and full test suite green. Co-Authored-By: Claude Opus 4.8 --- src/bin/dosh-client.rs | 58 ++- src/bin/dosh-server.rs | 792 +++++++++++++++++++++++++++++-------- src/config.rs | 22 ++ src/native.rs | 90 ++++- src/protocol.rs | 46 ++- tests/integration_smoke.rs | 311 +++++++++++++++ tests/protocol_auth.rs | 133 +++++++ 7 files changed, 1283 insertions(+), 169 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index e247746..770cac8 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -17,7 +17,7 @@ use dosh::native::{ use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, - NativeUserAuthBody, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, + NativeUserAuthBody, PacketKind, Rekey, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope, }; @@ -2145,6 +2145,9 @@ async fn run_terminal( None }; + // Retain the previous epoch's key briefly after a rekey so any in-flight + // pre-rekey frame still decrypts instead of triggering a needless reconnect. + let mut previous_session_key: Option<[u8; 32]> = None; let mut recv_buf = vec![0u8; 65535]; loop { tokio::select! { @@ -2185,7 +2188,16 @@ async fn run_terminal( } match packet.header.kind { PacketKind::Frame | PacketKind::ResumeOk => { - let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { + let decrypted = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) + .or_else(|err| { + // Fall back to the previous epoch key for in-flight + // pre-rekey frames before declaring the link stale. + match previous_session_key { + Some(prev) => protocol::decrypt_body(&packet, &prev, SERVER_TO_CLIENT), + None => Err(err), + } + }); + let Ok(plain) = decrypted else { if let Some(frame) = reconnect( &socket, &mut cred, @@ -2225,6 +2237,48 @@ async fn run_terminal( PacketKind::Pong => { last_packet_at = Instant::now(); } + PacketKind::Rekey => { + // Server-initiated transport rekey (spec §11). The Rekey is + // sealed under the current key; once decrypted we derive the + // next key from the shipped fresh material + current key, + // switch atomically (keeping the old key for grace), and + // confirm with a RekeyAck under the NEW key. + let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { + continue; + }; + let Ok(rekey) = protocol::from_body::(&plain) else { + continue; + }; + let previous_key = cred.session_key; + let previous_key_id = cred.session_key_id; + let Ok(new_key) = dosh::native::derive_rekey_session_key( + &previous_key, + &rekey.rekey_material, + &previous_key_id, + rekey.epoch, + ) else { + continue; + }; + // Only accept if our derivation matches the server's id. + if protocol::session_key_id(&new_key) != rekey.new_session_key_id { + continue; + } + previous_session_key = Some(previous_key); + cred.session_key = new_key; + cred.session_key_id = rekey.new_session_key_id; + last_packet_at = Instant::now(); + send_seq += 1; + let ack = protocol::encode_encrypted( + PacketKind::RekeyAck, + cred.client_id, + send_seq, + cred.last_rendered_seq, + &cred.session_key, + CLIENT_TO_SERVER, + b"", + )?; + socket.send_to(&ack, addr).await?; + } PacketKind::AttachReject => { let reject: AttachReject = protocol::from_body(&packet.body)?; if reject.reason == "unknown client" { diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 4fffdc2..bb185e7 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -20,7 +20,7 @@ use dosh::protocol::{ }; use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session}; use std::collections::{HashMap, HashSet, VecDeque}; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -143,6 +143,9 @@ async fn serve(config_path: Option) -> Result<()> { if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await { eprintln!("retransmit error: {err:#}"); } + if let Err(err) = maybe_rekey_clients(&retransmit_state, &retransmit_socket).await { + eprintln!("rekey error: {err:#}"); + } } }); @@ -171,6 +174,82 @@ struct ServerState { sessions: HashMap, pending_native: HashMap<[u8; 16], PendingNativeAuth>, next_server_stream_id: u64, + /// O(1) reverse index from a live client's `conn_id` to its session name, so + /// per-packet handlers don't linear-scan every session's client map. Kept in + /// lockstep with `Session::clients` on every insert and remove. + client_index: HashMap<[u8; 16], String>, + /// Per-source-IP token bucket for native ClientHello flood protection, + /// checked before any X25519/Ed25519 work. + native_auth_limiter: NativeAuthRateLimiter, +} + +/// Per-source-IP token bucket throttling native auth ClientHellos. +/// +/// Threat model item: a malicious unauthenticated client flooding auth attempts. +/// We charge one token per ClientHello *before* doing any X25519/Ed25519 crypto, +/// so a flood cannot burn server CPU. The bucket refills continuously at +/// `per_minute / 60` tokens per second up to a burst of `per_minute`, matching +/// the `native_auth_rate_limit_per_minute` value advertised in `ServerHello`. +struct NativeAuthRateLimiter { + per_minute: u32, + buckets: HashMap, +} + +#[derive(Clone, Copy)] +struct TokenBucket { + tokens: f64, + last_refill: Instant, +} + +impl NativeAuthRateLimiter { + fn new(per_minute: u32) -> Self { + Self { + per_minute, + buckets: HashMap::new(), + } + } + + /// Try to spend one token for `ip` at `now`. Returns `Ok(remaining)` whole + /// tokens left when allowed, or `Err(())` when the bucket is empty. A + /// `per_minute` of 0 disables native auth entirely (no tokens ever). + fn check(&mut self, ip: IpAddr, now: Instant) -> std::result::Result { + if self.per_minute == 0 { + return Err(()); + } + let capacity = self.per_minute as f64; + let refill_per_sec = capacity / 60.0; + let bucket = self.buckets.entry(ip).or_insert(TokenBucket { + tokens: capacity, + last_refill: now, + }); + let elapsed = now + .saturating_duration_since(bucket.last_refill) + .as_secs_f64(); + bucket.tokens = (bucket.tokens + elapsed * refill_per_sec).min(capacity); + bucket.last_refill = now; + if bucket.tokens < 1.0 { + return Err(()); + } + bucket.tokens -= 1.0; + Ok(bucket.tokens as u32) + } + + /// Drop buckets that have fully refilled, so the map can't grow unbounded + /// under a spoofed-source-IP flood. Called from the periodic cleanup task. + fn evict_full(&mut self, now: Instant) { + if self.per_minute == 0 { + self.buckets.clear(); + return; + } + let capacity = self.per_minute as f64; + let refill_per_sec = capacity / 60.0; + self.buckets.retain(|_, bucket| { + let elapsed = now + .saturating_duration_since(bucket.last_refill) + .as_secs_f64(); + (bucket.tokens + elapsed * refill_per_sec) < capacity + }); + } } struct Session { @@ -202,6 +281,15 @@ struct ClientState { opened_streams: HashSet, stream_send_credit: HashMap, stream_pending_data: HashMap>>, + /// Current transport key epoch (0 = original handshake key). Bumped on rekey. + epoch: u64, + /// When the current epoch began, for the wall-clock rekey trigger. + epoch_started: Instant, + /// Packets seen/sent in the current epoch, for the packet-count trigger. + epoch_packets: u64, + /// The previous epoch's key, retained briefly so in-flight pre-rekey packets + /// still decrypt (matched via `session_key_id`) instead of dropping fatally. + previous_session_key: Option<[u8; 32]>, } #[derive(Clone)] @@ -226,6 +314,7 @@ impl ServerState { secret: [u8; 32], pty_tx: mpsc::UnboundedSender, ) -> Self { + let per_minute = config.native_auth_rate_limit_per_minute; Self { config, secret, @@ -233,6 +322,8 @@ impl ServerState { sessions: HashMap::new(), pending_native: HashMap::new(), next_server_stream_id: 1u64 << 63, + client_index: HashMap::new(), + native_auth_limiter: NativeAuthRateLimiter::new(per_minute), } } @@ -283,6 +374,72 @@ impl ServerState { ); Ok(()) } + + /// Insert a client into `session_name` and record it in the O(1) index. The + /// session must already exist (callers `ensure_session` first). + fn insert_client(&mut self, session_name: &str, client_id: [u8; 16], client: ClientState) { + if let Some(session) = self.sessions.get_mut(session_name) { + session.clients.insert(client_id, client); + self.client_index + .insert(client_id, session_name.to_string()); + } + } + + /// Remove a client from whichever session holds it, keeping the index in + /// sync. Returns true when a client was actually removed. + fn remove_client_everywhere(&mut self, client_id: &[u8; 16]) -> bool { + if let Some(session_name) = self.client_index.remove(client_id) { + if let Some(session) = self.sessions.get_mut(&session_name) { + return session.clients.remove(client_id).is_some(); + } + } + false + } + + /// O(1) resolve of a live client's session key and session name via the + /// index, replacing the former O(sessions) linear scan. + fn lookup_client(&self, client_id: &[u8; 16]) -> Option<([u8; 32], String)> { + let session_name = self.client_index.get(client_id)?; + let session = self.sessions.get(session_name)?; + let client = session.clients.get(client_id)?; + Some((client.session_key, session_name.clone())) + } + + /// Resolve the decryption key for an inbound packet, honoring rekey grace. + /// + /// During the brief window after a rekey, in-flight packets sealed under the + /// previous epoch's key must still decrypt instead of being dropped as fatal + /// (spec: "stale encrypted packets ... ignored, not fatal"). We pick whichever + /// of the current or retained-previous key matches the packet's + /// `session_key_id`, falling back to the current key when neither matches (so + /// a genuinely stale/forged packet still fails the AEAD check, not silently). + fn lookup_client_key_for( + &self, + client_id: &[u8; 16], + session_key_id: &[u8; 16], + ) -> Option<([u8; 32], String)> { + let session_name = self.client_index.get(client_id)?; + let session = self.sessions.get(session_name)?; + let client = session.clients.get(client_id)?; + if protocol::session_key_id(&client.session_key) == *session_key_id { + return Some((client.session_key, session_name.clone())); + } + if let Some(previous) = client.previous_session_key + && protocol::session_key_id(&previous) == *session_key_id + { + return Some((previous, session_name.clone())); + } + Some((client.session_key, session_name.clone())) + } + + /// O(1) mutable access to a live client via the conn_id index. + fn client_mut(&mut self, client_id: &[u8; 16]) -> Option<&mut ClientState> { + let session_name = self.client_index.get(client_id)?; + self.sessions + .get_mut(session_name)? + .clients + .get_mut(client_id) + } } fn mode_uses_pty(mode: &str) -> bool { @@ -383,6 +540,14 @@ async fn handle_packet( peer: SocketAddr, raw: &[u8], ) -> Result<()> { + // A peer running a different wire protocol version cannot be decoded at all, + // because the header VERSION byte is part of the AEAD AAD and the framing. + // Rather than drop it (which the peer sees as a silent timeout), answer with + // a clear, named version-mismatch reject so the user is told to upgrade. + if let Some(foreign) = protocol::peek_foreign_wire_version(raw) { + let _ = foreign; + return send_reject(socket, peer, protocol::VERSION_MISMATCH_REASON).await; + } let packet = protocol::decode(raw)?; match packet.header.kind { PacketKind::NativeClientHello => { @@ -407,6 +572,7 @@ async fn handle_packet( | PacketKind::StreamClose | PacketKind::StreamEof | PacketKind::StreamWindowAdjust + | PacketKind::RekeyAck if find_client_key(state, &packet.header.conn_id).is_err() => { send_reject_to_client(socket, peer, packet.header.conn_id, "unknown client").await @@ -414,7 +580,8 @@ async fn handle_packet( PacketKind::Input => handle_input(state, peer, &packet).await, PacketKind::Resize => handle_resize(state, peer, &packet).await, PacketKind::Ping => handle_ping(state, socket, peer, &packet).await, - PacketKind::Ack => handle_ack(state, &packet).await, + PacketKind::Ack => handle_ack(state, peer, &packet).await, + PacketKind::RekeyAck => handle_rekey_ack(state, peer, &packet).await, PacketKind::Detach => handle_detach(state, &packet).await, PacketKind::StreamOpen => handle_stream_open(state, socket, peer, &packet).await, PacketKind::StreamOpenOk => handle_stream_open_ok(state, socket, peer, &packet).await, @@ -423,7 +590,7 @@ async fn handle_packet( PacketKind::StreamWindowAdjust => { handle_stream_window_adjust(state, socket, peer, &packet).await } - PacketKind::StreamClose => handle_stream_close(state, &packet).await, + PacketKind::StreamClose => handle_stream_close(state, peer, &packet).await, _ => Ok(()), } } @@ -435,6 +602,27 @@ async fn handle_native_client_hello( body: Vec, ) -> Result<()> { let req: NativeClientHelloBody = protocol::from_body(&body)?; + // Defense in depth against a peer that shares our wire VERSION but advertises + // a different native handshake protocol_version (the spec's negotiation + // point). Reject with a named, actionable reason before any crypto. + if let Err(err) = + dosh::native::check_native_protocol_version(req.hello.protocol_version, "client") + { + return send_reject(socket, peer, &err.to_string()).await; + } + // Rate-limit native auth *before* touching the host key or any X25519/Ed25519 + // work, so a flood of ClientHellos from one source cannot burn server CPU. + // A separate, non-crypto reject lets a legitimate client back off cleanly. + let rate_limit_remaining = { + let mut locked = state.lock().expect("server state poisoned"); + locked.native_auth_limiter.check(peer.ip(), Instant::now()) + }; + let rate_limit_remaining = match rate_limit_remaining { + Ok(remaining) => remaining, + Err(()) => { + return send_reject(socket, peer, "native auth rate limit exceeded").await; + } + }; let built: Result<([u8; 16], NativeServerHello)> = { let mut locked = state.lock().expect("server state poisoned"); if !locked.config.native_auth { @@ -468,7 +656,7 @@ async fn handle_native_client_hello( chosen_aead: "chacha20poly1305".to_string(), server_key_epoch: 1, auth_challenge: crypto::random_32(), - rate_limit_remaining: Some(locked.config.native_auth_rate_limit_per_minute), + rate_limit_remaining: Some(rate_limit_remaining), host_signature: Vec::new(), }; sign_server_hello(&host_signing, &req.hello, &mut hello)?; @@ -637,7 +825,8 @@ async fn handle_native_user_auth( let client_id = crypto::random_16(); let snapshot = screen_snapshot(session.parser.screen()); let output_seq = session.output_seq; - session.clients.insert( + locked.insert_client( + &session_name, client_id, ClientState { endpoint: peer, @@ -655,6 +844,10 @@ async fn handle_native_user_auth( opened_streams: HashSet::new(), stream_send_credit: HashMap::new(), stream_pending_data: HashMap::new(), + epoch: 0, + epoch_started: Instant::now(), + epoch_packets: 0, + previous_session_key: None, }, ); Ok(( @@ -765,7 +958,9 @@ async fn handle_bootstrap_attach( let client_id = crypto::random_16(); let snapshot = screen_snapshot(session.parser.screen()); let output_seq = session.output_seq; - session.clients.insert( + let bootstrap_session = req.bootstrap.session.clone(); + locked.insert_client( + &bootstrap_session, client_id, ClientState { endpoint: peer, @@ -783,6 +978,10 @@ async fn handle_bootstrap_attach( opened_streams: HashSet::new(), stream_send_credit: HashMap::new(), stream_pending_data: HashMap::new(), + epoch: 0, + epoch_started: Instant::now(), + epoch_packets: 0, + previous_session_key: None, }, ); ( @@ -878,7 +1077,9 @@ async fn handle_ticket_attach( let client_id = crypto::random_16(); let snapshot = screen_snapshot(session.parser.screen()); let output_seq = session.output_seq; - session.clients.insert( + let ticket_session = req.session.clone(); + locked.insert_client( + &ticket_session, client_id, ClientState { endpoint: peer, @@ -896,6 +1097,10 @@ async fn handle_ticket_attach( opened_streams: HashSet::new(), stream_send_credit: HashMap::new(), stream_pending_data: HashMap::new(), + epoch: 0, + epoch_started: Instant::now(), + epoch_packets: 0, + previous_session_key: None, }, ); (client_id, output_seq, snapshot) @@ -956,7 +1161,7 @@ async fn handle_resume( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = match find_client_key(state, &packet.header.conn_id) { + let (key, session_name) = match find_client_decrypt_key(state, &packet.header) { Ok(found) => found, Err(_) => return send_reject(socket, peer, "unknown client").await, }; @@ -1018,7 +1223,7 @@ async fn handle_input( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let input: Input = protocol::from_body(&body)?; let mut locked = state.lock().expect("server state poisoned"); @@ -1053,7 +1258,7 @@ async fn handle_resize( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let resize: Resize = protocol::from_body(&body)?; let mut locked = state.lock().expect("server state poisoned"); @@ -1068,8 +1273,11 @@ async fn handle_resize( if !client.replay.accept(packet.header.seq) { return Ok(()); } + // Connection migration happens for any authenticated, fresh packet (spec §11), + // independent of the session mode; a view-only client roams too. + client.endpoint = peer; + client.last_seen = Instant::now(); if mode_allows_terminal_updates(&client.mode) { - client.endpoint = peer; client.cols = resize.cols; client.rows = resize.rows; apply_terminal_size( @@ -1088,22 +1296,26 @@ async fn handle_ping( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, _) = find_client_key(state, &packet.header.conn_id)?; + let (key, _) = find_client_decrypt_key(state, &packet.header)?; + // Authenticate the ping (verify its AEAD tag) BEFORE acting on it. Pings carry + // an empty plaintext but still a tag, so this rejects a spoofed ping that + // could otherwise replay-poison or, worse, hijack `endpoint` migration. + let _ = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let seq = { let mut locked = state.lock().expect("server state poisoned"); - let mut found = None; - for session in locked.sessions.values_mut() { - if let Some(client) = session.clients.get_mut(&packet.header.conn_id) { - if !client.replay.accept(packet.header.seq) { - return Ok(()); - } - client.last_seen = Instant::now(); - client.send_seq += 1; - found = Some(client.send_seq); - break; - } + let client = locked + .client_mut(&packet.header.conn_id) + .ok_or_else(|| anyhow!("unknown client"))?; + if !client.replay.accept(packet.header.seq) { + return Ok(()); } - found.ok_or_else(|| anyhow!("unknown client"))? + // Connection migration: any authenticated, fresh packet from a new source + // address roams the session there (spec §11). Gated on replay-accept so a + // captured old packet can't redirect the stream. + client.endpoint = peer; + client.last_seen = Instant::now(); + client.send_seq += 1; + client.send_seq }; let out = protocol::encode_encrypted( PacketKind::Pong, @@ -1120,39 +1332,64 @@ async fn handle_ping( async fn handle_detach(state: &Arc>, packet: &protocol::Packet) -> Result<()> { let mut locked = state.lock().expect("server state poisoned"); - for session in locked.sessions.values_mut() { - session.clients.remove(&packet.header.conn_id); - } + locked.remove_client_everywhere(&packet.header.conn_id); Ok(()) } fn remove_client(state: &Arc>, client_id: [u8; 16]) { let mut locked = state.lock().expect("server state poisoned"); - for session in locked.sessions.values_mut() { - session.clients.remove(&client_id); - } + locked.remove_client_everywhere(&client_id); } -async fn handle_ack(state: &Arc>, packet: &protocol::Packet) -> Result<()> { - let (key, _) = find_client_key(state, &packet.header.conn_id)?; +async fn handle_ack( + state: &Arc>, + peer: SocketAddr, + packet: &protocol::Packet, +) -> Result<()> { + let (key, _) = find_client_decrypt_key(state, &packet.header)?; let _ = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let mut locked = state.lock().expect("server state poisoned"); - for session in locked.sessions.values_mut() { - if let Some(client) = session.clients.get_mut(&packet.header.conn_id) { - if !client.replay.accept(packet.header.seq) { - return Ok(()); - } - client.last_seen = Instant::now(); - client.last_acked = packet.header.ack; - while client - .pending - .front() - .is_some_and(|pending| pending.output_seq <= packet.header.ack) - { - client.pending.pop_front(); - } + if let Some(client) = locked.client_mut(&packet.header.conn_id) { + if !client.replay.accept(packet.header.seq) { return Ok(()); } + // Connection migration on any authenticated, fresh packet (spec §11). + client.endpoint = peer; + client.last_seen = Instant::now(); + client.last_acked = packet.header.ack; + while client + .pending + .front() + .is_some_and(|pending| pending.output_seq <= packet.header.ack) + { + client.pending.pop_front(); + } + } + Ok(()) +} + +/// The client confirms it has switched to the new epoch key. Sealed under the +/// new (now-current) key, so decrypting it authenticates the confirmation. We +/// retire the previous-epoch key immediately, ending the grace early. +async fn handle_rekey_ack( + state: &Arc>, + peer: SocketAddr, + packet: &protocol::Packet, +) -> Result<()> { + let (key, _) = find_client_decrypt_key(state, &packet.header)?; + let _ = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; + let mut locked = state.lock().expect("server state poisoned"); + if let Some(client) = locked.client_mut(&packet.header.conn_id) { + if !client.replay.accept(packet.header.seq) { + return Ok(()); + } + client.endpoint = peer; + client.last_seen = Instant::now(); + // Only retire the old key if the ack was sealed under the *current* key, + // confirming the client really is on the new epoch. + if protocol::session_key_id(&client.session_key) == packet.header.session_key_id { + client.previous_session_key = None; + } } Ok(()) } @@ -1163,7 +1400,7 @@ async fn handle_stream_open( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let open: StreamOpen = protocol::from_body(&body)?; let allowed = { @@ -1276,7 +1513,7 @@ async fn handle_stream_data( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let data: StreamData = protocol::from_body(&body)?; let writer = { @@ -1314,7 +1551,7 @@ async fn handle_stream_open_ok( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let ok: StreamOpenOk = protocol::from_body(&body)?; { @@ -1346,7 +1583,7 @@ async fn handle_stream_open_reject( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let reject: StreamOpenReject = protocol::from_body(&body)?; let mut locked = state.lock().expect("server state poisoned"); @@ -1376,7 +1613,7 @@ async fn handle_stream_window_adjust( peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let adjust: StreamWindowAdjust = protocol::from_body(&body)?; { @@ -1406,9 +1643,10 @@ async fn handle_stream_window_adjust( async fn handle_stream_close( state: &Arc>, + peer: SocketAddr, packet: &protocol::Packet, ) -> Result<()> { - let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let (key, session_name) = find_client_decrypt_key(state, &packet.header)?; let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; let close: StreamClose = protocol::from_body(&body)?; let mut locked = state.lock().expect("server state poisoned"); @@ -1421,6 +1659,8 @@ async fn handle_stream_close( if !client.replay.accept(packet.header.seq) { return Ok(()); } + // Connection migration on any authenticated, fresh packet (spec §11). + client.endpoint = peer; client.last_seen = Instant::now(); client.stream_writers.remove(&close.stream_id); client.opened_streams.remove(&close.stream_id); @@ -1650,42 +1890,39 @@ async fn queue_or_send_stream_data_to_client( let send = { let mut locked = state.lock().expect("server state poisoned"); let mut send = None; - for session in locked.sessions.values_mut() { - if let Some(client) = session.clients.get_mut(&client_id) { - if !client.opened_streams.contains(&stream_id) - || client - .stream_send_credit - .get(&stream_id) - .copied() - .unwrap_or(0) - < bytes.len() - || client - .stream_pending_data - .get(&stream_id) - .is_some_and(|pending| !pending.is_empty()) - { - client - .stream_pending_data - .entry(stream_id) - .or_default() - .push_back(bytes); - return Ok(()); - } - *client.stream_send_credit.entry(stream_id).or_default() -= bytes.len(); - client.send_seq += 1; - let body = protocol::to_body(&StreamData { stream_id, bytes })?; - let packet = protocol::encode_encrypted( - PacketKind::StreamData, - client_id, - client.send_seq, - client.last_acked, - &client.session_key, - SERVER_TO_CLIENT, - &body, - )?; - send = Some((client.endpoint, packet)); - break; + if let Some(client) = locked.client_mut(&client_id) { + if !client.opened_streams.contains(&stream_id) + || client + .stream_send_credit + .get(&stream_id) + .copied() + .unwrap_or(0) + < bytes.len() + || client + .stream_pending_data + .get(&stream_id) + .is_some_and(|pending| !pending.is_empty()) + { + client + .stream_pending_data + .entry(stream_id) + .or_default() + .push_back(bytes); + return Ok(()); } + *client.stream_send_credit.entry(stream_id).or_default() -= bytes.len(); + client.send_seq += 1; + let body = protocol::to_body(&StreamData { stream_id, bytes })?; + let packet = protocol::encode_encrypted( + PacketKind::StreamData, + client_id, + client.send_seq, + client.last_acked, + &client.session_key, + SERVER_TO_CLIENT, + &body, + )?; + send = Some((client.endpoint, packet)); } send }; @@ -1705,42 +1942,39 @@ async fn flush_stream_pending_data_to_client( let send = { let mut locked = state.lock().expect("server state poisoned"); let mut send = None; - for session in locked.sessions.values_mut() { - if let Some(client) = session.clients.get_mut(&client_id) { - let Some(pending) = client.stream_pending_data.get_mut(&stream_id) else { - return Ok(()); - }; - let Some(bytes) = pending.front() else { - client.stream_pending_data.remove(&stream_id); - return Ok(()); - }; - let credit = client - .stream_send_credit - .get(&stream_id) - .copied() - .unwrap_or(0); - if credit < bytes.len() { - return Ok(()); - } - let bytes = pending.pop_front().expect("pending front exists"); - if pending.is_empty() { - client.stream_pending_data.remove(&stream_id); - } - *client.stream_send_credit.entry(stream_id).or_default() -= bytes.len(); - client.send_seq += 1; - let body = protocol::to_body(&StreamData { stream_id, bytes })?; - let packet = protocol::encode_encrypted( - PacketKind::StreamData, - client_id, - client.send_seq, - client.last_acked, - &client.session_key, - SERVER_TO_CLIENT, - &body, - )?; - send = Some((client.endpoint, packet)); - break; + if let Some(client) = locked.client_mut(&client_id) { + let Some(pending) = client.stream_pending_data.get_mut(&stream_id) else { + return Ok(()); + }; + let Some(bytes) = pending.front() else { + client.stream_pending_data.remove(&stream_id); + return Ok(()); + }; + let credit = client + .stream_send_credit + .get(&stream_id) + .copied() + .unwrap_or(0); + if credit < bytes.len() { + return Ok(()); } + let bytes = pending.pop_front().expect("pending front exists"); + if pending.is_empty() { + client.stream_pending_data.remove(&stream_id); + } + *client.stream_send_credit.entry(stream_id).or_default() -= bytes.len(); + client.send_seq += 1; + let body = protocol::to_body(&StreamData { stream_id, bytes })?; + let packet = protocol::encode_encrypted( + PacketKind::StreamData, + client_id, + client.send_seq, + client.last_acked, + &client.session_key, + SERVER_TO_CLIENT, + &body, + )?; + send = Some((client.endpoint, packet)); } send }; @@ -1785,14 +2019,11 @@ async fn send_stream_close_to_client( ) -> Result<()> { { let mut locked = state.lock().expect("server state poisoned"); - for session in locked.sessions.values_mut() { - if let Some(client) = session.clients.get_mut(&client_id) { - client.stream_writers.remove(&stream_id); - client.opened_streams.remove(&stream_id); - client.stream_send_credit.remove(&stream_id); - client.stream_pending_data.remove(&stream_id); - break; - } + if let Some(client) = locked.client_mut(&client_id) { + client.stream_writers.remove(&stream_id); + client.opened_streams.remove(&stream_id); + client.stream_send_credit.remove(&stream_id); + client.stream_pending_data.remove(&stream_id); } } let body = protocol::to_body(&StreamClose { stream_id })?; @@ -1809,21 +2040,18 @@ async fn send_stream_packet_to_client( let send = { let mut locked = state.lock().expect("server state poisoned"); let mut found = None; - for session in locked.sessions.values_mut() { - if let Some(client) = session.clients.get_mut(&client_id) { - client.send_seq += 1; - let packet = protocol::encode_encrypted( - kind, - client_id, - client.send_seq, - client.last_acked, - &client.session_key, - SERVER_TO_CLIENT, - &body, - )?; - found = Some((client.endpoint, packet)); - break; - } + if let Some(client) = locked.client_mut(&client_id) { + client.send_seq += 1; + let packet = protocol::encode_encrypted( + kind, + client_id, + client.send_seq, + client.last_acked, + &client.session_key, + SERVER_TO_CLIENT, + &body, + )?; + found = Some((client.endpoint, packet)); } found }; @@ -1860,6 +2088,8 @@ async fn broadcast_output( let mut sends = Vec::new(); for (client_id, client) in session.clients.iter_mut() { client.send_seq += 1; + // Count traffic toward the packet-count rekey trigger (spec §11). + client.epoch_packets = client.epoch_packets.saturating_add(1); // Only materialize a screen snapshot when this client has fallen too // far behind; the common case sends the raw output bytes and must not // clone the whole vt100 grid per client per packet. @@ -1919,6 +2149,7 @@ async fn broadcast_session_exit( let output_seq = session.output_seq; let mut sends = Vec::new(); for (client_id, mut client) in session.clients.drain() { + locked.client_index.remove(&client_id); client.send_seq += 1; let frame = Frame { session: session_name.clone(), @@ -1990,6 +2221,102 @@ async fn retransmit_pending( Ok(()) } +/// How long the previous epoch's key is retained after a rekey so in-flight +/// pre-rekey packets still decrypt instead of being dropped as fatal. +const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5; + +/// Rotate transport traffic keys for any client past its rekey threshold (spec +/// §11). Triggers on either a packet count or a wall-clock interval, configured +/// via `rekey_after_packets` / `rekey_after_secs`. The new key is derived from +/// fresh server material independent of the handshake keys +/// (see [`dosh::native::derive_rekey_session_key`]); the server installs it +/// immediately, retains the old key for a short grace, and ships the fresh +/// material to the client in a `Rekey` packet sealed under the *current* key. +async fn maybe_rekey_clients( + state: &Arc>, + socket: &Arc, +) -> Result<()> { + let sends = { + let mut locked = state.lock().expect("server state poisoned"); + let after_packets = locked.config.rekey_after_packets; + let after_secs = locked.config.rekey_after_secs; + if after_packets == 0 && after_secs == 0 { + return Ok(()); + } + let now = Instant::now(); + let grace = Duration::from_secs(REKEY_PREVIOUS_KEY_GRACE_SECS); + let mut sends = Vec::new(); + for session in locked.sessions.values_mut() { + for (client_id, client) in session.clients.iter_mut() { + // Expire the retained previous-epoch key once its grace is over. + if client.previous_session_key.is_some() + && now.duration_since(client.epoch_started) >= grace + { + client.previous_session_key = None; + } + let by_packets = after_packets != 0 && client.epoch_packets >= after_packets; + let by_time = after_secs != 0 + && now.duration_since(client.epoch_started).as_secs() >= after_secs; + if !(by_packets || by_time) { + continue; + } + // Don't start a new rekey while the previous epoch's grace is + // still active, so we never juggle three live keys at once. + if client.previous_session_key.is_some() { + continue; + } + let previous_key = client.session_key; + let previous_key_id = protocol::session_key_id(&previous_key); + let new_epoch = client.epoch.saturating_add(1); + let rekey_material = crypto::random_32(); + let new_key = match dosh::native::derive_rekey_session_key( + &previous_key, + &rekey_material, + &previous_key_id, + new_epoch, + ) { + Ok(key) => key, + Err(err) => { + eprintln!("rekey derive error: {err:#}"); + continue; + } + }; + let new_key_id = protocol::session_key_id(&new_key); + let rekey = protocol::Rekey { + epoch: new_epoch, + rekey_material, + new_session_key_id: new_key_id, + }; + let body = protocol::to_body(&rekey)?; + client.send_seq += 1; + // The Rekey itself is sealed under the CURRENT key so the client + // can decrypt it before switching. + let packet = protocol::encode_encrypted( + PacketKind::Rekey, + *client_id, + client.send_seq, + client.last_acked, + &previous_key, + SERVER_TO_CLIENT, + &body, + )?; + // Install the new key, keep the old one for the grace window. + client.previous_session_key = Some(previous_key); + client.session_key = new_key; + client.epoch = new_epoch; + client.epoch_started = now; + client.epoch_packets = 0; + sends.push((client.endpoint, packet)); + } + } + sends + }; + for (endpoint, packet) in sends { + socket.send_to(&packet, endpoint).await?; + } + Ok(()) +} + fn cleanup_disconnected_clients(state: &Arc>) { // Sessions removed here are dropped after the lock is released so their // shells are killed (PtyHandle::drop) without holding up the event loop. @@ -1998,16 +2325,26 @@ fn cleanup_disconnected_clients(state: &Arc>) { let timeout = Duration::from_secs(locked.config.client_timeout_secs.max(1)); let now = Instant::now(); let prewarm: HashSet = locked.config.prewarm_sessions.iter().cloned().collect(); + // Collect timed-out clients first so we can purge them from the O(1) + // conn_id index too, keeping it in lockstep with `Session::clients`. + let mut timed_out: Vec<[u8; 16]> = Vec::new(); for session in locked.sessions.values_mut() { - session - .clients - .retain(|_, client| now.duration_since(client.last_seen) <= timeout); + session.clients.retain(|client_id, client| { + let keep = now.duration_since(client.last_seen) <= timeout; + if !keep { + timed_out.push(*client_id); + } + keep + }); if session.clients.is_empty() { session.empty_since.get_or_insert(now); } else { session.empty_since = None; } } + for client_id in timed_out { + locked.client_index.remove(&client_id); + } // Evict half-finished native handshakes. Each unauthenticated ClientHello // inserts a PendingNativeAuth that is otherwise only removed by a matching // UserAuth, so without this a flood of hellos grows the map without bound. @@ -2015,6 +2352,9 @@ fn cleanup_disconnected_clients(state: &Arc>) { locked .pending_native .retain(|_, pending| now.duration_since(pending.created_at) < handshake_ttl); + // Drop fully-refilled rate-limit buckets so a spoofed-source flood cannot + // grow the map without bound. + locked.native_auth_limiter.evict_full(now); // Reap sessions that have been clientless past the grace period. Prewarmed // sessions stay hot on purpose, even with no clients attached. let to_reap: Vec = locked @@ -2041,12 +2381,22 @@ fn find_client_key( client_id: &[u8; 16], ) -> Result<([u8; 32], String)> { let locked = state.lock().expect("server state poisoned"); - for (name, session) in &locked.sessions { - if let Some(client) = session.clients.get(client_id) { - return Ok((client.session_key, name.clone())); - } - } - Err(anyhow!("unknown client")) + locked + .lookup_client(client_id) + .ok_or_else(|| anyhow!("unknown client")) +} + +/// Like [`find_client_key`] but selects the key (current or retained previous +/// epoch) matching the packet header's `session_key_id`, so a packet sealed +/// under the pre-rekey key during the grace window still decrypts. +fn find_client_decrypt_key( + state: &Arc>, + header: &protocol::Header, +) -> Result<([u8; 32], String)> { + let locked = state.lock().expect("server state poisoned"); + locked + .lookup_client_key_for(&header.conn_id, &header.session_key_id) + .ok_or_else(|| anyhow!("unknown client")) } fn parse_nonce(raw: &str) -> Result<[u8; 12]> { @@ -2067,6 +2417,134 @@ fn parse_size(raw: &str) -> Result<(u16, u16)> { mod tests { use super::*; + #[test] + fn native_auth_rate_limiter_throttles_per_ip_and_refills() { + let mut limiter = NativeAuthRateLimiter::new(3); + let ip_a: IpAddr = "10.0.0.1".parse().unwrap(); + let ip_b: IpAddr = "10.0.0.2".parse().unwrap(); + let t0 = Instant::now(); + + // Burst of 3 allowed, 4th rejected for ip_a. + assert!(limiter.check(ip_a, t0).is_ok()); + assert!(limiter.check(ip_a, t0).is_ok()); + assert!(limiter.check(ip_a, t0).is_ok()); + assert!(limiter.check(ip_a, t0).is_err()); + + // A different source IP has its own independent bucket. + assert!(limiter.check(ip_b, t0).is_ok()); + + // After 20s at 3/min (one token every 20s), ip_a gets exactly one back. + let t1 = t0 + Duration::from_secs(20); + assert!(limiter.check(ip_a, t1).is_ok()); + assert!(limiter.check(ip_a, t1).is_err()); + } + + #[test] + fn native_auth_rate_limiter_zero_per_minute_blocks_everything() { + let mut limiter = NativeAuthRateLimiter::new(0); + assert!( + limiter + .check("127.0.0.1".parse().unwrap(), Instant::now()) + .is_err() + ); + } + + #[test] + fn native_auth_rate_limiter_evicts_refilled_buckets() { + let mut limiter = NativeAuthRateLimiter::new(60); + let ip: IpAddr = "10.0.0.9".parse().unwrap(); + let t0 = Instant::now(); + assert!(limiter.check(ip, t0).is_ok()); + assert_eq!(limiter.buckets.len(), 1); + // 60/min = one token per second; after 60s the bucket is full again. + limiter.evict_full(t0 + Duration::from_secs(60)); + assert!(limiter.buckets.is_empty()); + } + + fn test_client_state(session_key: [u8; 32]) -> ClientState { + ClientState { + endpoint: "127.0.0.1:9".parse().unwrap(), + mode: "forward-only".to_string(), + session_key, + last_acked: 0, + replay: ReplayWindow::default(), + send_seq: 1, + cols: 80, + rows: 24, + last_seen: Instant::now(), + pending: VecDeque::new(), + allowed_forwardings: Vec::new(), + stream_writers: HashMap::new(), + opened_streams: HashSet::new(), + stream_send_credit: HashMap::new(), + stream_pending_data: HashMap::new(), + epoch: 0, + epoch_started: Instant::now(), + epoch_packets: 0, + previous_session_key: None, + } + } + + #[test] + fn client_index_stays_in_sync_with_session_clients() { + let (pty_tx, _pty_rx) = mpsc::unbounded_channel(); + let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx); + state + .ensure_session("work", 80, 24, "forward-only", &[]) + .unwrap(); + let client_id = [5u8; 16]; + let key = [6u8; 32]; + + // Inserting through the helper records the index for O(1) lookup. + state.insert_client("work", client_id, test_client_state(key)); + assert_eq!( + state.client_index.get(&client_id).map(String::as_str), + Some("work") + ); + assert_eq!( + state.lookup_client(&client_id), + Some((key, "work".to_string())) + ); + assert!(state.client_mut(&client_id).is_some()); + + // Removing purges both the session map and the index. + assert!(state.remove_client_everywhere(&client_id)); + assert!(!state.client_index.contains_key(&client_id)); + assert!(state.lookup_client(&client_id).is_none()); + assert!(state.client_mut(&client_id).is_none()); + assert!(!state.sessions["work"].clients.contains_key(&client_id)); + // Removing an absent client is a no-op. + assert!(!state.remove_client_everywhere(&client_id)); + } + + #[test] + fn cleanup_purges_timed_out_clients_from_index() { + let (pty_tx, _pty_rx) = mpsc::unbounded_channel(); + let config = ServerConfig { + client_timeout_secs: 1, + ..ServerConfig::default() + }; + let mut state = ServerState::new(config, [0u8; 32], pty_tx); + state + .ensure_session("work", 80, 24, "forward-only", &[]) + .unwrap(); + let client_id = [8u8; 16]; + let mut client = test_client_state([1u8; 32]); + // Make the client look stale so cleanup reaps it. + client.last_seen = Instant::now() - Duration::from_secs(60); + state.insert_client("work", client_id, client); + + let state = Arc::new(Mutex::new(state)); + cleanup_disconnected_clients(&state); + + let locked = state.lock().unwrap(); + assert!( + !locked.client_index.contains_key(&client_id), + "timed-out client must be dropped from the index, not leaked" + ); + assert!(locked.lookup_client(&client_id).is_none()); + } + #[test] fn forward_only_session_does_not_allocate_pty() { let (pty_tx, _pty_rx) = mpsc::unbounded_channel(); @@ -2196,6 +2674,10 @@ mod tests { opened_streams: HashSet::new(), stream_send_credit: HashMap::new(), stream_pending_data: HashMap::new(), + epoch: 0, + epoch_started: Instant::now(), + epoch_packets: 0, + previous_session_key: None, }, )]), output_seq: 0, @@ -2203,6 +2685,8 @@ mod tests { empty_since: None, }, ); + // Keep the O(1) conn_id index in sync, matching `insert_client`. + state.client_index.insert(client_id, "test".to_string()); let state = Arc::new(Mutex::new(state)); queue_or_send_stream_data_to_client(&state, &sender, client_id, 42, b"hello".to_vec()) diff --git a/src/config.rs b/src/config.rs index 0fb0f45..3f96c9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,14 @@ pub struct ServerConfig { pub authorized_keys: Vec, #[serde(default = "default_native_auth_rate_limit_per_minute")] pub native_auth_rate_limit_per_minute: u32, + /// Rotate a client's transport traffic key after this many packets in the + /// current epoch (spec §11). `0` disables the packet-count trigger. + #[serde(default = "default_rekey_after_packets")] + pub rekey_after_packets: u64, + /// Rotate a client's transport traffic key after this many wall-clock seconds + /// in the current epoch (spec §11). `0` disables the time trigger. + #[serde(default = "default_rekey_after_secs")] + pub rekey_after_secs: u64, #[serde(default = "default_true")] pub allow_tcp_forwarding: bool, #[serde(default)] @@ -61,6 +69,8 @@ impl Default for ServerConfig { host_key: default_host_key(), authorized_keys: default_authorized_keys(), native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(), + rekey_after_packets: default_rekey_after_packets(), + rekey_after_secs: default_rekey_after_secs(), allow_tcp_forwarding: true, allow_remote_forwarding: false, allow_remote_non_loopback_bind: false, @@ -175,6 +185,18 @@ fn default_native_auth_rate_limit_per_minute() -> u32 { 30 } +fn default_rekey_after_packets() -> u64 { + // Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a + // per-direction monotonic seq is safe far beyond this, but a bounded epoch + // keeps forward-secrecy windows small. + 100_000 +} + +fn default_rekey_after_secs() -> u64 { + // One hour per epoch by default. + 3600 +} + fn default_auth_preference() -> String { "native,ssh".to_string() } diff --git a/src/native.rs b/src/native.rs index d3af508..765d20f 100644 --- a/src/native.rs +++ b/src/native.rs @@ -15,7 +15,7 @@ use std::str::FromStr; use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519"; -pub const NATIVE_PROTOCOL_VERSION: u8 = 1; +pub const NATIVE_PROTOCOL_VERSION: u8 = 2; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HostPublicKey { @@ -248,17 +248,55 @@ pub fn sign_server_hello( Ok(()) } +/// Error raised when a native handshake peer speaks a different protocol version. +/// +/// Carries the peer's advertised version so callers can render an actionable, +/// named message ("upgrade dosh") rather than letting a mismatch surface as an +/// opaque decrypt failure or a silent timeout. The `Display` text deliberately +/// embeds [`crate::protocol::VERSION_MISMATCH_REASON`] so the server's reject and +/// the client's local error read the same way. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProtocolVersionMismatch { + pub local: u8, + pub remote: u8, + pub peer: &'static str, +} + +impl std::fmt::Display for ProtocolVersionMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} ({} speaks native protocol v{}, this build speaks v{})", + crate::protocol::VERSION_MISMATCH_REASON, + self.peer, + self.remote, + self.local, + ) + } +} + +impl std::error::Error for ProtocolVersionMismatch {} + +/// Verify a peer-advertised native protocol version against this build. +/// +/// `peer` names whose version was wrong ("client" or "server") for the error +/// message. Returns a typed [`ProtocolVersionMismatch`] so the caller can both +/// match on it and print an actionable, upgrade-oriented message. +pub fn check_native_protocol_version(version: u8, peer: &'static str) -> Result<()> { + if version != NATIVE_PROTOCOL_VERSION { + return Err(ProtocolVersionMismatch { + local: NATIVE_PROTOCOL_VERSION, + remote: version, + peer, + } + .into()); + } + Ok(()) +} + pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> { - anyhow::ensure!( - client.protocol_version == NATIVE_PROTOCOL_VERSION, - "unsupported native client protocol {}", - client.protocol_version - ); - anyhow::ensure!( - server.protocol_version == NATIVE_PROTOCOL_VERSION, - "unsupported native server protocol {}", - server.protocol_version - ); + check_native_protocol_version(client.protocol_version, "client")?; + check_native_protocol_version(server.protocol_version, "server")?; let transcript = server_hello_transcript(client, server)?; verify_host_signature(&server.host_key, &transcript, &server.host_signature) } @@ -369,6 +407,36 @@ pub fn derive_native_session_key( crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1") } +/// Derive a rotated transport key for transport rekey (spec §11 / §9). +/// +/// The rotated key is derived **independently of the handshake traffic keys**: +/// the input keying material is the current epoch's key (which both peers already +/// hold) mixed with fresh, server-generated `rekey_material` (32 bytes of CSPRNG +/// output, delivered confidentially inside an AEAD `Rekey` packet sealed under +/// the current key). The new `epoch` and the previous epoch's `session_key_id` +/// salt the derivation so each epoch's key is unique. It never re-derives from +/// the handshake DH output, satisfying the spec requirement that "rotated session +/// keys must be derived independently ... from fresh randomness" and "must not +/// reuse handshake traffic keys." +/// +/// Both peers run this identically — the client never needs the server's +/// long-term secret, only the fresh `rekey_material` it receives in the `Rekey` +/// packet plus the current key it already shares. +pub fn derive_rekey_session_key( + current_key: &[u8; 32], + rekey_material: &[u8; 32], + previous_session_key_id: &[u8; 16], + epoch: u64, +) -> Result<[u8; 32]> { + let mut ikm = Vec::with_capacity(64); + ikm.extend_from_slice(current_key); + ikm.extend_from_slice(rekey_material); + let mut salt = b"dosh/native/rekey/v1".to_vec(); + salt.extend_from_slice(previous_session_key_id); + salt.extend_from_slice(&epoch.to_be_bytes()); + crypto::hkdf32(&ikm, &salt, b"dosh/native/rekey/chacha20poly1305/v1") +} + pub fn load_ed25519_identity(path: &Path) -> Result { load_ed25519_identity_with_passphrase(path, None) } diff --git a/src/protocol.rs b/src/protocol.rs index ac6ac84..f4c9add 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -5,8 +5,14 @@ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; pub const MAGIC: &[u8; 4] = b"DOSH"; -pub const VERSION: u8 = 1; +pub const VERSION: u8 = 2; pub const HEADER_LEN: usize = 58; + +/// Stable, user-facing reason string the server puts in an `AttachReject` when a +/// native handshake arrives carrying a `protocol_version` it cannot speak. The +/// client recognizes this prefix and surfaces a clear "upgrade dosh" message +/// instead of the generic transport rejection or, worse, a silent timeout. +pub const VERSION_MISMATCH_REASON: &str = "protocol version mismatch — upgrade dosh"; const HEADER_AAD_LEN: usize = HEADER_LEN - 2; pub const CLIENT_TO_SERVER: u32 = 1; pub const SERVER_TO_CLIENT: u32 = 2; @@ -39,6 +45,8 @@ pub enum PacketKind { StreamEof = 23, StreamClose = 24, NativeAuthCheckOk = 25, + Rekey = 26, + RekeyAck = 27, } impl TryFrom for PacketKind { @@ -71,6 +79,8 @@ impl TryFrom for PacketKind { 23 => Self::StreamEof, 24 => Self::StreamClose, 25 => Self::NativeAuthCheckOk, + 26 => Self::Rekey, + 27 => Self::RekeyAck, _ => bail!("unknown packet kind {value}"), }) } @@ -110,7 +120,11 @@ impl Header { bail!("bad magic"); } if input[4] != VERSION { - bail!("bad protocol version {}", input[4]); + bail!( + "{} (peer wire protocol v{}, this build speaks v{VERSION})", + VERSION_MISMATCH_REASON, + input[4] + ); } let kind = PacketKind::try_from(input[5])?; let flags = u16::from_be_bytes(input[6..8].try_into().unwrap()); @@ -133,6 +147,20 @@ impl Header { } } +/// Cheaply inspect a datagram that carries our [`MAGIC`] but whose wire +/// [`VERSION`] byte differs from this build's. Returns the peer's advertised +/// wire version when (and only when) the packet is a Dosh packet we cannot +/// otherwise decode because of a version skew, so the receiver can answer with a +/// clear, named version-mismatch reject instead of dropping it (a silent +/// timeout for the peer). Returns `None` for our own version, foreign magic, or +/// runt packets. +pub fn peek_foreign_wire_version(input: &[u8]) -> Option { + if input.len() < 5 || &input[..4] != MAGIC || input[4] == VERSION { + return None; + } + Some(input[4]) +} + #[derive(Debug, Clone)] pub struct Packet { pub header: Header, @@ -369,6 +397,20 @@ pub struct StreamClose { pub stream_id: u64, } +/// Server→client transport rekey, sealed under the *current* session key. +/// +/// Carries the fresh server-generated `rekey_material` and the new `epoch`; both +/// peers feed these into [`crate::native::derive_rekey_session_key`] to compute +/// the next traffic key. `new_session_key_id` is the id the next epoch's packets +/// will carry, so the client can recognize and switch atomically. The client +/// replies with a [`PacketKind::RekeyAck`] encrypted under the *new* key. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rekey { + pub epoch: u64, + pub rekey_material: [u8; 32], + pub new_session_key_id: [u8; 16], +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Frame { pub session: String, diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index 0acf62c..54c75aa 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -1246,3 +1246,314 @@ fn resume_updates_udp_endpoint_for_roaming() { "expected output on resumed socket, got {text:?}" ); } + +#[test] +fn transport_rekey_round_trip_keeps_session_alive() { + use dosh::native::derive_rekey_session_key; + use dosh::protocol::Rekey; + + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + // Rotate after a single packet so a rekey fires almost immediately. + let mut raw = fs::read_to_string(&config).unwrap(); + raw.push_str("rekey_after_packets = 1\n"); + fs::write(&config, raw).unwrap(); + let mut server = start_server(&dir, &config); + + let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + socket + .set_read_timeout(Some(Duration::from_millis(300))) + .unwrap(); + + // Track the live transport key; it rotates when a Rekey arrives. + let mut current_key = bootstrap.session_key; + let mut current_key_id = protocol::session_key_id(¤t_key); + let mut send_seq = 2u64; + + // Produce some output so the server starts sending frames (and rekeys). + let input = Input { + bytes: b"printf DOSH_REKEY_ONE\\n\n".to_vec(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + send_seq, + 0, + ¤t_key, + &protocol::to_body(&input).unwrap(), + ); + send_seq += 1; + + // Drive the loop: decrypt frames under the live key, and when a Rekey lands, + // adopt the new epoch key and ack it. Confirm a post-rekey input still works. + let mut rekeyed = false; + let mut saw_post_rekey_output = false; + let mut buf = [0u8; 65535]; + let deadline = std::time::Instant::now() + Duration::from_secs(8); + while std::time::Instant::now() < deadline { + let Ok((n, _)) = socket.recv_from(&mut buf) else { + continue; + }; + let Ok(packet) = protocol::decode(&buf[..n]) else { + continue; + }; + match packet.header.kind { + PacketKind::Rekey => { + let plain = + protocol::decrypt_body(&packet, ¤t_key, SERVER_TO_CLIENT).unwrap(); + let rekey: Rekey = protocol::from_body(&plain).unwrap(); + let new_key = derive_rekey_session_key( + ¤t_key, + &rekey.rekey_material, + ¤t_key_id, + rekey.epoch, + ) + .unwrap(); + assert_eq!( + protocol::session_key_id(&new_key), + rekey.new_session_key_id, + "client-derived rekey key id must match the server's" + ); + current_key = new_key; + current_key_id = rekey.new_session_key_id; + // Ack under the NEW key. + send_encrypted( + &socket, + port, + PacketKind::RekeyAck, + ok.client_id, + send_seq, + 0, + ¤t_key, + b"", + ); + send_seq += 1; + rekeyed = true; + // Now send a fresh input under the new key. + let input = Input { + bytes: b"printf DOSH_REKEY_TWO\\n\n".to_vec(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + send_seq, + 0, + ¤t_key, + &protocol::to_body(&input).unwrap(), + ); + send_seq += 1; + } + PacketKind::Frame | PacketKind::ResumeOk => { + if let Ok(plain) = protocol::decrypt_body(&packet, ¤t_key, SERVER_TO_CLIENT) { + if let Ok(frame) = protocol::from_body::(&plain) { + let text = String::from_utf8_lossy(&frame.bytes); + if rekeyed && text.contains("DOSH_REKEY_TWO") { + saw_post_rekey_output = true; + break; + } + } + } + } + _ => {} + } + } + + let _ = server.kill(); + let _ = server.wait(); + + assert!(rekeyed, "server never initiated a rekey"); + assert!( + saw_post_rekey_output, + "post-rekey input/output round trip failed under the new epoch key" + ); +} + +#[test] +fn input_from_new_source_address_migrates_connection() { + // Spec §11: the server must accept client source-address migration after ANY + // valid authenticated/encrypted packet from a new address, not just resume. + 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 (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + // A fresh socket = a new source address (new ephemeral port). The very first + // packet from it is an ordinary encrypted Input, not a ResumeRequest. + let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + new_socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let input = Input { + bytes: b"printf DOSH_MIGRATE\\n\n".to_vec(), + }; + let packet = protocol::encode_encrypted( + PacketKind::Input, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&input).unwrap(), + ) + .unwrap(); + new_socket + .send_to(&packet, format!("127.0.0.1:{port}")) + .unwrap(); + + // Output frames for this input must now be delivered to the NEW socket, + // proving the server migrated `endpoint` off the original address. + let text = collect_frame_text(&new_socket, &bootstrap.session_key, 2000); + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + text.contains("DOSH_MIGRATE"), + "expected server output on the migrated socket, got {text:?}" + ); +} + +#[test] +fn native_auth_rate_limit_rejects_flood_before_crypto() { + use dosh::native::{NATIVE_PROTOCOL_VERSION, NativeClientHello}; + use dosh::protocol::NativeClientHelloBody; + + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + // Squeeze the rate limit down to 2/min so a short burst trips it. + let mut raw = fs::read_to_string(&config).unwrap(); + raw.push_str("native_auth_rate_limit_per_minute = 2\n"); + fs::write(&config, raw).unwrap(); + write_native_client_auth(&dir, &config); + let mut server = start_server(&dir, &config); + + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); + let make_hello = || { + let (_, client_public) = dosh::native::generate_native_ephemeral(); + let hello = NativeClientHello { + protocol_version: NATIVE_PROTOCOL_VERSION, + client_random: crypto::random_32(), + client_ephemeral_public: client_public, + requested_host: "local".to_string(), + requested_user: user.clone(), + requested_session: "default".to_string(), + requested_mode: "read-write".to_string(), + terminal_size: (80, 24), + supported_aead: vec!["chacha20poly1305".to_string()], + supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], + cached_host_key_fingerprint: None, + attach_ticket_envelope: None, + requested_env: Vec::new(), + }; + protocol::encode_plain( + PacketKind::NativeClientHello, + [0u8; 16], + 1, + 0, + &protocol::to_body(&NativeClientHelloBody { hello }).unwrap(), + ) + .unwrap() + }; + + let mut reasons = Vec::new(); + // Burst of hellos; with a 2/min budget the later ones must be rejected. + for _ in 0..6 { + socket + .send_to(&make_hello(), format!("127.0.0.1:{port}")) + .unwrap(); + let mut buf = [0u8; 65535]; + if let Ok((n, _)) = socket.recv_from(&mut buf) { + let packet = protocol::decode(&buf[..n]).unwrap(); + if packet.header.kind == PacketKind::AttachReject { + let reject: AttachReject = protocol::from_body(&packet.body).unwrap(); + reasons.push(reject.reason); + } + } + } + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + reasons.iter().any(|r| r.contains("rate limit")), + "expected a rate-limit reject within the burst, got {reasons:?}" + ); +} + +#[test] +fn native_hello_with_mismatched_protocol_version_gets_named_reject_not_hang() { + use dosh::native::{NATIVE_PROTOCOL_VERSION, NativeClientHello}; + use dosh::protocol::{NativeClientHelloBody, VERSION_MISMATCH_REASON}; + + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + write_native_client_auth(&dir, &config); + let mut server = start_server(&dir, &config); + + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + + // Same wire VERSION as the server (so the datagram decodes), but an + // incompatible native handshake protocol_version. This is the spec's + // plaintext negotiation point: the server must answer with a clear, named + // reject rather than letting the client time out. + let hello = NativeClientHello { + protocol_version: NATIVE_PROTOCOL_VERSION.wrapping_add(1), + client_random: crypto::random_32(), + client_ephemeral_public: [3u8; 32], + requested_host: "local".to_string(), + requested_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()), + requested_session: "default".to_string(), + requested_mode: "read-write".to_string(), + terminal_size: (80, 24), + supported_aead: vec!["chacha20poly1305".to_string()], + supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], + cached_host_key_fingerprint: None, + attach_ticket_envelope: None, + requested_env: Vec::new(), + }; + let packet = protocol::encode_plain( + PacketKind::NativeClientHello, + [0u8; 16], + 1, + 0, + &protocol::to_body(&NativeClientHelloBody { hello }).unwrap(), + ) + .unwrap(); + socket + .send_to(&packet, format!("127.0.0.1:{port}")) + .unwrap(); + + let mut buf = [0u8; 65535]; + // recv with the 2s read timeout above: a hang would surface as a recv error + // here instead of a reject, which is exactly what this test guards against. + let (n, _) = socket + .recv_from(&mut buf) + .expect("server must answer a version-mismatched hello, not hang"); + let packet = protocol::decode(&buf[..n]).unwrap(); + let reject: AttachReject = protocol::from_body(&packet.body).unwrap(); + + let _ = server.kill(); + let _ = server.wait(); + + assert_eq!(packet.header.kind, PacketKind::AttachReject); + assert!( + reject.reason.contains(VERSION_MISMATCH_REASON), + "expected a named protocol-version-mismatch reject, got {:?}", + reject.reason + ); +} diff --git a/tests/protocol_auth.rs b/tests/protocol_auth.rs index 43f7f9f..1166359 100644 --- a/tests/protocol_auth.rs +++ b/tests/protocol_auth.rs @@ -126,6 +126,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() { ); } +#[test] +fn peek_foreign_wire_version_flags_only_version_skew() { + let key = crypto::random_32(); + let mut packet = protocol::encode_encrypted( + PacketKind::Input, + crypto::random_16(), + 1, + 0, + &key, + CLIENT_TO_SERVER, + b"hi", + ) + .unwrap(); + // A correctly framed packet for this build is not "foreign". + assert_eq!(protocol::peek_foreign_wire_version(&packet), None); + // Bumping the wire version byte makes it undecodable but recognizable. + packet[4] = protocol::VERSION.wrapping_add(7); + assert_eq!( + protocol::peek_foreign_wire_version(&packet), + Some(protocol::VERSION.wrapping_add(7)) + ); + assert!(protocol::decode(&packet).is_err()); + // Non-Dosh datagrams and runts are ignored. + assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None); + assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None); +} + +#[test] +fn rekey_key_derivation_agrees_and_is_independent_per_epoch() { + use dosh::native::derive_rekey_session_key; + + // The handshake/current key both peers already share. + let current_key = crypto::random_32(); + let current_id = protocol::session_key_id(¤t_key); + // Fresh server-generated material, delivered confidentially in the Rekey. + let material = crypto::random_32(); + + // Both peers derive identically from shared current key + shipped material. + let server_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap(); + let client_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap(); + assert_eq!(server_view, client_view); + + // The rotated key must not equal the handshake/current key. + assert_ne!(server_view, current_key); + + // A different epoch (or different material) yields a different key. + let next_epoch = derive_rekey_session_key(¤t_key, &material, ¤t_id, 2).unwrap(); + assert_ne!(server_view, next_epoch); + let other_material = crypto::random_32(); + let other = derive_rekey_session_key(¤t_key, &other_material, ¤t_id, 1).unwrap(); + assert_ne!(server_view, other); +} + +#[test] +fn rekey_round_trip_decrypts_old_and_new_epoch_packets() { + use dosh::native::derive_rekey_session_key; + + let key_epoch0 = crypto::random_32(); + let id0 = protocol::session_key_id(&key_epoch0); + let conn_id = crypto::random_16(); + + // Pre-rekey packet sealed under epoch-0 key. + let pre = protocol::encode_encrypted( + PacketKind::Frame, + conn_id, + 5, + 0, + &key_epoch0, + protocol::SERVER_TO_CLIENT, + b"before", + ) + .unwrap(); + + // Rotate to epoch 1. + let material = crypto::random_32(); + let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap(); + let post = protocol::encode_encrypted( + PacketKind::Frame, + conn_id, + 6, + 0, + &key_epoch1, + protocol::SERVER_TO_CLIENT, + b"after", + ) + .unwrap(); + + let pre = protocol::decode(&pre).unwrap(); + let post = protocol::decode(&post).unwrap(); + + // Each epoch's key carries its own session_key_id; the receiver picks the + // right one and both decrypt correctly. + assert_eq!(pre.header.session_key_id, id0); + assert_eq!( + pre.header.session_key_id, + protocol::session_key_id(&key_epoch0) + ); + assert_eq!( + post.header.session_key_id, + protocol::session_key_id(&key_epoch1) + ); + assert_eq!( + protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(), + b"before" + ); + assert_eq!( + protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(), + b"after" + ); + + // A stale-epoch packet under the wrong key is rejected via session_key_id + // BEFORE any AEAD work — ignorable, not a fatal decrypt error. + let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err(); + assert!(err.to_string().contains("session key id")); +} + +#[test] +fn check_native_protocol_version_names_the_mismatch() { + use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version}; + check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap(); + let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server") + .unwrap_err(); + let message = err.to_string(); + assert!( + message.contains(protocol::VERSION_MISMATCH_REASON), + "expected actionable upgrade message, got {message:?}" + ); + assert!( + message.contains("server"), + "should name the wrong peer: {message:?}" + ); +} + #[test] fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() { let mut replay = ReplayWindow::new(8);