From d0d6f59cdfd0b8b647162b9f36bc6ec2601d4e50 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:45:27 -0400 Subject: [PATCH] Add launch hardening gates --- .github/workflows/ci.yml | 19 +- Makefile | 13 +- README.md | 30 ++- docs/NATIVE_V1_SPEC.md | 19 +- docs/PROTOCOL_VERSIONING.md | 40 ++++ docs/PUBLIC_READINESS.md | 32 ++-- docs/THREAT_MODEL.md | 32 ++-- fuzz/README.md | 12 +- scripts/fuzz-run.sh | 22 +++ tests/hostile_network.rs | 362 +++++++++++++++++++++++++++++++++++- tests/integration_smoke.rs | 63 +++++++ 11 files changed, 583 insertions(+), 61 deletions(-) create mode 100644 docs/PROTOCOL_VERSIONING.md create mode 100755 scripts/fuzz-run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1abe8..ae92c07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,9 @@ name: ci on: push: pull_request: + workflow_dispatch: + schedule: + - cron: "17 7 * * 1" jobs: test: @@ -36,17 +39,11 @@ jobs: 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 + if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh + else + sh scripts/fuzz-run.sh 20 + fi - 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." diff --git a/Makefile b/Makefile index fd7f2d6..68197ca 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh +.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local build: cargo build --release @@ -28,3 +28,14 @@ bench-docker-ssh: bench-docker-mosh: DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh + +fuzz-smoke: + sh scripts/fuzz-run.sh 20 + +# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN. +fuzz-deep: + DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh + +# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN. +soak-local: + DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture diff --git a/README.md b/README.md index cb0401c..3cca3d9 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,20 @@ fast path after the first authentication has issued an attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature parity yet. +Run the explicit pre-launch soak and fuzz gates: + +```bash +make soak-local # 30-minute sleep/roaming gate by default +make fuzz-deep # 5 minutes per fuzz target by default +``` + +Both are configurable for shorter local shakedowns: + +```bash +DOSH_SOAK_SECONDS=30 make soak-local +DOSH_FUZZ_SECONDS=60 make fuzz-deep +``` + The CI workflow includes an optional remote benchmark job. It runs when `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are configured. @@ -303,11 +317,11 @@ 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**. Protocol VERSION compatibility policy, -long-running sleep/roaming soak tests, adversarial forwarding/network tests, deeper -fuzzing, 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. +Native v1 is **not yet fully verified**. Long-running sleep/roaming soak evidence, +reliable packet-loss recovery for forwarded stream data, deeper fuzzing evidence, +and an external security review are still open. See `docs/THREAT_MODEL.md` for the +published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md` +for the v1 versioning policy, 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/docs/NATIVE_V1_SPEC.md b/docs/NATIVE_V1_SPEC.md index 2f8c5ab..b7d8711 100644 --- a/docs/NATIVE_V1_SPEC.md +++ b/docs/NATIVE_V1_SPEC.md @@ -13,16 +13,17 @@ > the default with explicit, visible SSH fallback. Local and Docker benchmark gates > cover cached attach, SSH fallback, and native cold auth (Track C / > `BENCHMARKS.md`). -> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`, -> `-f`, and per-stream flow control exist; terminal-priority/load regressions are -> covered, and hostile-network/long-soak proof is still being expanded before parity -> is claimed. +> - Milestone 4 — forwarding: **implemented with one reliability caveat.** Stream +> mux, `-L`, `-R`, `-D`, `-N`, `-f`, and per-stream flow control exist; +> terminal-priority/load and replay/reorder regressions are covered. Dropped +> `StreamData` recovery still needs stream retransmission before lossy-link +> forwarding parity is claimed. > - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting, -> fail-closed protocol-version checks, parser fuzz targets, fuzz-smoke CI, scripted -> TUI transport tests, forwarding load/priority tests, and independent persistent -> session restart tests exist. External review, long soak, and future multi-version -> compatibility policy are not yet complete. The threat model is published -> (`docs/THREAT_MODEL.md`). +> fail-closed protocol-version checks with a documented v1 policy, parser fuzz +> targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests, +> forwarding load/priority/replay tests, and independent persistent session restart +> tests exist. External review and published long-soak evidence 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. diff --git a/docs/PROTOCOL_VERSIONING.md b/docs/PROTOCOL_VERSIONING.md new file mode 100644 index 0000000..2976cd0 --- /dev/null +++ b/docs/PROTOCOL_VERSIONING.md @@ -0,0 +1,40 @@ +# Dosh Protocol Versioning + +Dosh v1 uses a deliberately simple compatibility policy: **single-version, +fail-closed, explicit error**. + +The wire header carries `protocol::VERSION`. The native handshake carries +`native::NATIVE_PROTOCOL_VERSION`. A peer that speaks any other version is rejected +before application data is accepted: + +- foreign wire `VERSION` packets get an `AttachReject` with + `protocol version mismatch - upgrade dosh`; +- foreign native handshake `protocol_version` values get the same named upgrade + error with local/remote versions; +- there is no silent downgrade, compatibility fallback, or best-effort decoding. + +## When To Bump + +Bump `protocol::VERSION` when a change affects packet framing, packet kind meaning, +serialized protocol structs carried outside native handshake negotiation, or anything +an older peer could misparse. + +Bump `native::NATIVE_PROTOCOL_VERSION` when a change affects native handshake +transcripts, native auth semantics, algorithm negotiation, attach tickets, or any +field that is signed or key-derived by native auth. + +If both layers are affected, bump both. + +## Compatibility Window + +Native v1 supports exactly the current version. That keeps the implementation small +and makes security review tractable. Multi-version negotiation can be added later +only with an explicit downgrade-resistance design: + +- negotiated version must be transcript-bound; +- the selected version must be visible in diagnostics; +- tests must prove an active attacker cannot force an older mutually supported + version; +- unsupported peers must still get the same named upgrade error. + +Until that exists, the public policy is: upgrade both sides together. diff --git a/docs/PUBLIC_READINESS.md b/docs/PUBLIC_READINESS.md index 21ec022..b22fd49 100644 --- a/docs/PUBLIC_READINESS.md +++ b/docs/PUBLIC_READINESS.md @@ -10,8 +10,8 @@ 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, `dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the feature matrix and the verification-checklist status table below). It is **not yet -fully verified**: long sleep/roaming soak, adversarial UDP forwarding tests, deeper -fuzzing, protocol-version compatibility policy, and external review are still open. +fully verified**: long sleep/roaming soak, packet-loss recovery for forwarding, +deeper fuzzing results, 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 @@ -63,8 +63,8 @@ with ordinary SSH. | `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 | implemented; needs more hostile-network/soak proof | -| Survive sleep or network loss | yes | yes | needs long-running soak tests | +| Roaming by client address change | yes | yes | implemented; hostile-network covered, 30-minute soak gate available | +| Survive sleep or network loss | yes | yes | implemented; run `make soak-local` before public launch | | Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator | | Resident server daemon | no | yes | core differentiator | | One UDP port for all sessions | port range by default | yes | ready | @@ -78,9 +78,9 @@ 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 | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only | -| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority tested, adversarial UDP pending | -| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; adversarial UDP pending | -| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; adversarial UDP pending | +| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority and replay/reorder tested; packet-loss recovery pending | +| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; packet-loss recovery pending | +| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; packet-loss recovery pending | | 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; covered by blocked-stream and local-forward load tests | @@ -130,7 +130,9 @@ Implemented: Still open before claiming forwarding parity: -- A dedicated dropped/reordered/replayed-UDP forwarding test suite. +- Reliable packet-loss recovery for forwarded stream data. Reorder and replay are + covered by `forwarded_stream_data_survives_reorder_and_rejects_replay`; true + dropped `StreamData` recovery requires stream retransmission. - More real-host load soak. The integration suite already covers terminal-priority behavior while a local forward is under blocked-stream pressure. @@ -162,19 +164,18 @@ from code; in progress = partially implemented; pending = not yet implemented. | `-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. | +| Closing laptop 30+ min does not kill session | in progress | `sleep_roaming_soak_30m` is an ignored launch gate; run `make soak-local` for 1800s evidence. | | 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 | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. | -| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets and CI runs a nightly `fuzz-smoke` job when cargo-fuzz is available. | +| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. | | 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: implemented for native auth and covered by unit/integration tests. It still needs real-host tuning under abusive traffic. -- Protocol VERSION handling: current behavior is single-version, fail-closed reject - at the packet/header and native-handshake layers. Future multi-version - compatibility/downgrade policy is still open. +- Protocol VERSION handling: v1 policy is single-version, fail-closed reject at the + packet/header and native-handshake layers. See `docs/PROTOCOL_VERSIONING.md`. - ECDSA P-256 / SHA-2 RSA user keys: implemented for ssh-agent and OpenSSH identity-file native auth. RSA is compatibility-only and uses SHA-2 signatures; legacy SHA-1 `ssh-rsa` signatures are not accepted. @@ -182,12 +183,17 @@ Additional security hardening tracked outside the section 16 list: ## Before Public Launch - Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green. +- Run `make soak-local` before launch to produce 30-minute sleep/roaming evidence. +- Run `make fuzz-deep` before launch, or use the scheduled/manual CI fuzz pass, and + publish the target durations. - Keep scripted TUI tests green and add a broader app matrix for real alternate-screen tools. - Publish benchmark output with raw samples, not just averages. - Mark prediction as experimental until it has a real framebuffer model. - Stress-test `persist_sessions = true` before making restart-survivable holders the default. +- Add reliable retransmission for forwarded stream data before claiming TCP + forwarding survives arbitrary UDP packet loss. - Tune the native auth token bucket under abusive real-host traffic. - 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 index 471b195..23a2f5b 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -188,24 +188,28 @@ be evaluated honestly. Items here are *not* yet "green". half-finished handshakes on a TTL, and reports remaining capacity in `ServerHello`. It is covered by unit/integration tests, but still needs abusive real-host tuning before public hardening claims. -- **Protocol VERSION compatibility policy is still simple.** 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 smoke-tested in CI, not yet a deep campaign.** CI runs parser/auth - fuzz targets briefly with nightly/cargo-fuzz when available. That catches obvious - panics and parser robustness regressions; it is not a substitute for longer - coverage-guided fuzz campaigns before public security claims. +- **Protocol VERSION compatibility is intentionally single-version for v1.** The + packet header rejects any non-matching `VERSION` byte and the native handshake + rejects any non-matching `protocol_version`; peers get a named upgrade error, not + a silent downgrade. `docs/PROTOCOL_VERSIONING.md` defines the bump rules and the + post-v1 requirements for any future multi-version negotiation. +- **Deep fuzzing still needs launch evidence.** CI runs parser/auth fuzz targets for + 20 seconds per target on push/PR and 300 seconds per target on weekly/manual runs + when nightly/cargo-fuzz is available. That catches obvious panics and parser + robustness regressions; public security claims should cite a completed + `make fuzz-deep` or scheduled CI run with durations. - **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256, and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH identity files. RSA remains compatibility-only and deliberately rejects legacy SHA-1 `ssh-rsa` signatures. -- **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. +- **Forwarded stream data is not yet reliable across arbitrary UDP loss.** Terminal + frames have retransmit coverage; forwarding streams have flow control and + replay/reorder tests, but dropped `StreamData` recovery requires stream + retransmission before Dosh should claim TCP-forwarding parity on lossy links. +- **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and + multi-client tests exist, and `sleep_roaming_soak_30m` / `make soak-local` provide + the 30-minute sleep/roaming gate. That gate should be run and published before + public security/reliability claims. - **No external security review yet.** The spec's milestone 5 requires an external review checklist before public security claims. That review has not happened. diff --git a/fuzz/README.md b/fuzz/README.md index 8f57b28..ad52255 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -35,6 +35,12 @@ cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`). From the repository root: ```sh +# Run all targets for a short smoke pass +make fuzz-smoke + +# Run all targets for the pre-launch deep pass (default: 300s each) +make fuzz-deep + # List targets cargo +nightly fuzz list --fuzz-dir fuzz @@ -55,5 +61,7 @@ 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. +cargo-fuzz and runs each target. Push/PR runs use a short 20-second-per-target +smoke pass. Weekly scheduled and manual workflow runs use a 300-second-per-target +deep pass by default. The job is tolerant if the toolchain/tooling is unavailable +so it never blocks the main test gate. diff --git a/scripts/fuzz-run.sh b/scripts/fuzz-run.sh new file mode 100755 index 0000000..3def2cc --- /dev/null +++ b/scripts/fuzz-run.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}" +targets=" +packet_decode +from_body +authorized_keys +known_hosts +handshake_structs +attach_ticket +" + +if ! command -v cargo >/dev/null 2>&1; then + [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" +fi + +for target in $targets; do + echo "== fuzzing $target for ${seconds}s ==" + cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \ + -max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}" +done diff --git a/tests/hostile_network.rs b/tests/hostile_network.rs index 206fa68..b6906db 100644 --- a/tests/hostile_network.rs +++ b/tests/hostile_network.rs @@ -20,7 +20,8 @@ //! tests are reproducible and fast. use std::fs; -use std::net::{SocketAddr, UdpSocket}; +use std::io::Read; +use std::net::{SocketAddr, TcpListener, UdpSocket}; use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -28,13 +29,18 @@ use std::sync::mpsc::{Receiver, Sender, channel}; use std::thread; use std::time::{Duration, Instant}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret}; use dosh::config::load_server_config; use dosh::crypto; +use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello}; use dosh::protocol::{ - self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest, - SERVER_TO_CLIENT, + self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody, + NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest, + SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk, }; +use ed25519_dalek::{SigningKey, VerifyingKey}; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; @@ -79,6 +85,23 @@ persist_sessions = false config } +fn authorize_ed25519_key(dir: &tempfile::TempDir, signing_key: &SigningKey) { + let verifying = VerifyingKey::from(signing_key); + let mut blob = Vec::new(); + write_ssh_string(&mut blob, b"ssh-ed25519"); + write_ssh_string(&mut blob, verifying.as_bytes()); + fs::write( + dir.path().join("authorized_keys"), + format!("ssh-ed25519 {} hostile-test\n", STANDARD.encode(blob)), + ) + .unwrap(); +} + +fn write_ssh_string(out: &mut Vec, value: &[u8]) { + out.extend_from_slice(&(value.len() as u32).to_be_bytes()); + out.extend_from_slice(value); +} + fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { let server = env!("CARGO_BIN_EXE_dosh-server"); let child = Command::new(server) @@ -402,6 +425,122 @@ fn attach_through_relay( } } +struct NativeAttached { + socket: UdpSocket, + ok: NativeAuthOk, +} + +fn native_attach_through_relay( + relay: &Relay, + signing_key: &SigningKey, + requested_forwardings: Vec, +) -> NativeAttached { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let (client_secret, client_public) = native::generate_native_ephemeral(); + let hello = NativeClientHello { + protocol_version: native::NATIVE_PROTOCOL_VERSION, + client_random: crypto::random_32(), + client_ephemeral_public: client_public, + requested_host: "127.0.0.1".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: native::supported_user_key_algorithms(), + 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: hello.clone(), + }) + .unwrap(), + ) + .unwrap(); + let hello_deadline = Instant::now() + Duration::from_secs(5); + let server_hello = loop { + socket.send_to(&packet, relay.front_addr()).unwrap(); + let mut buf = [0u8; 65535]; + match socket.recv_from(&mut buf) { + Ok((n, _)) => { + let packet = protocol::decode(&buf[..n]).unwrap(); + match packet.header.kind { + PacketKind::NativeServerHello => { + let body: NativeServerHelloBody = + protocol::from_body(&packet.body).unwrap(); + native::verify_server_hello(&hello, &body.hello).unwrap(); + break body.hello; + } + PacketKind::AttachReject => { + let reject: AttachReject = protocol::from_body(&packet.body).unwrap(); + panic!("native hello rejected: {}", reject.reason); + } + kind => panic!("unexpected native hello response: {kind:?}"), + } + } + Err(_) if Instant::now() < hello_deadline => {} + Err(err) => panic!("native server hello timed out: {err}"), + } + }; + + let session_key = native::derive_native_session_key( + &client_secret, + server_hello.server_ephemeral_public, + &hello, + &server_hello, + ) + .unwrap(); + let auth = + native::sign_user_auth(signing_key, &hello, &server_hello, requested_forwardings).unwrap(); + let mut pending_id = [0u8; 16]; + pending_id.copy_from_slice(&server_hello.auth_challenge[..16]); + let auth_packet = protocol::encode_encrypted( + PacketKind::NativeUserAuth, + pending_id, + 2, + 1, + &session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&NativeUserAuthBody { auth }).unwrap(), + ) + .unwrap(); + let auth_deadline = Instant::now() + Duration::from_secs(5); + let ok = loop { + socket.send_to(&auth_packet, relay.front_addr()).unwrap(); + let mut buf = [0u8; 65535]; + match socket.recv_from(&mut buf) { + Ok((n, _)) => { + let packet = protocol::decode(&buf[..n]).unwrap(); + match packet.header.kind { + PacketKind::NativeAuthOk => { + let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT) + .unwrap(); + let body: NativeAuthOkBody = protocol::from_body(&plain).unwrap(); + break body.ok; + } + PacketKind::AttachReject => { + let reject: AttachReject = protocol::from_body(&packet.body).unwrap(); + panic!("native auth rejected: {}", reject.reason); + } + kind => panic!("unexpected native auth response: {kind:?}"), + } + } + Err(_) if Instant::now() < auth_deadline => {} + Err(err) => panic!("native auth ok timed out: {err}"), + } + }; + NativeAttached { socket, ok } +} + fn send_input( socket: &UdpSocket, relay: &Relay, @@ -431,6 +570,105 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) { socket.send_to(packet, relay.front_addr()).unwrap(); } +fn send_stream_packet( + socket: &UdpSocket, + relay: &Relay, + ok: &NativeAuthOk, + kind: PacketKind, + seq: u64, + ack: u64, + body: Vec, +) { + let packet = protocol::encode_encrypted( + kind, + ok.client_id, + seq, + ack, + &ok.session_key, + CLIENT_TO_SERVER, + &body, + ) + .unwrap(); + socket.send_to(&packet, relay.front_addr()).unwrap(); +} + +fn recv_stream_packet( + socket: &UdpSocket, + key: &[u8; 32], + kind: PacketKind, +) -> Option<(Header, T)> { + let mut buf = [0u8; 65535]; + let (n, _) = socket.recv_from(&mut buf).ok()?; + let packet = protocol::decode(&buf[..n]).ok()?; + if packet.header.kind != kind { + return None; + } + let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?; + let body = protocol::from_body(&plain).ok()?; + Some((packet.header, body)) +} + +fn wait_for_stream_open_ok( + socket: &UdpSocket, + key: &[u8; 32], + stream_id: u64, + millis: u64, +) -> Option
{ + let deadline = Instant::now() + Duration::from_millis(millis); + while Instant::now() < deadline { + if let Some((header, ok)) = + recv_stream_packet::(socket, key, PacketKind::StreamOpenOk) + { + if ok.stream_id == stream_id { + return Some(header); + } + } + } + None +} + +fn start_tcp_collector() -> (u16, Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = channel(); + thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut buf = [0u8; 1024]; + while Instant::now() < deadline { + match stream.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + let _ = tx.send(buf[..n].to_vec()); + } + Err(ref err) + if err.kind() == std::io::ErrorKind::WouldBlock + || err.kind() == std::io::ErrorKind::TimedOut => {} + Err(_) => return, + } + } + }); + (port, rx) +} + +fn collect_tcp(rx: &Receiver>, millis: u64) -> Vec { + let deadline = Instant::now() + Duration::from_millis(millis); + let mut out = Vec::new(); + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(chunk) => out.extend_from_slice(&chunk), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + } + out +} + fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> { let mut buf = [0u8; 65535]; let (n, _) = socket.recv_from(&mut buf).ok()?; @@ -603,6 +841,124 @@ fn duplicated_and_replayed_input_is_applied_at_most_once() { ); } +#[test] +fn forwarded_stream_data_survives_reorder_and_rejects_replay() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let signing_key = SigningKey::from_bytes(&[42u8; 32]); + authorize_ed25519_key(&dir, &signing_key); + let (target_port, tcp_rx) = start_tcp_collector(); + let mut server = start_server(&dir, &config); + let relay = Relay::spawn(port, 0xF0E0D0u64); + + let attached = native_attach_through_relay( + &relay, + &signing_key, + vec![ForwardingRequest { + kind: ForwardingKind::Local, + bind_host: Some("127.0.0.1".to_string()), + listen_port: 0, + target_host: Some("127.0.0.1".to_string()), + target_port: Some(target_port), + }], + ); + let stream_id = 44; + let open = StreamOpen { + stream_id, + target_host: "127.0.0.1".to_string(), + target_port, + }; + send_stream_packet( + &attached.socket, + &relay, + &attached.ok, + PacketKind::StreamOpen, + 3, + attached.ok.initial_seq, + protocol::to_body(&open).unwrap(), + ); + let open_ok = + wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000) + .expect("stream did not open"); + + // Swap the first two StreamData packets in flight. The server's replay + // window must accept both out-of-order packets and write both to the TCP + // target exactly once. + relay.arm_reorder(); + send_stream_packet( + &attached.socket, + &relay, + &attached.ok, + PacketKind::StreamData, + 4, + open_ok.seq, + protocol::to_body(&StreamData { + stream_id, + bytes: b"A".to_vec(), + }) + .unwrap(), + ); + relay.arm_reorder(); + send_stream_packet( + &attached.socket, + &relay, + &attached.ok, + PacketKind::StreamData, + 5, + open_ok.seq, + protocol::to_body(&StreamData { + stream_id, + bytes: b"B".to_vec(), + }) + .unwrap(), + ); + + let mut seen = collect_tcp(&tcp_rx, 1500); + assert!( + seen.contains(&b'A') && seen.contains(&b'B'), + "reordered stream bytes did not both reach TCP target: {:?}", + String::from_utf8_lossy(&seen) + ); + + // Now send one encrypted StreamData packet repeatedly, with relay-level + // duplication enabled too. This is a true replay: same sequence, same nonce, + // same ciphertext. It must be applied to the TCP target at most once. + let replayed = protocol::encode_encrypted( + PacketKind::StreamData, + attached.ok.client_id, + 6, + open_ok.seq, + &attached.ok.session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&StreamData { + stream_id, + bytes: b"X".to_vec(), + }) + .unwrap(), + ) + .unwrap(); + relay.set_dup(100); + for _ in 0..12 { + send_raw(&attached.socket, &relay, &replayed); + thread::sleep(Duration::from_millis(25)); + } + relay.set_dup(0); + + seen.extend(collect_tcp(&tcp_rx, 1500)); + let replay_count = seen.iter().filter(|byte| **byte == b'X').count(); + + drop(relay); + let _ = server.kill(); + let _ = server.wait(); + + assert!( + replay_count <= 1, + "replayed/duplicated StreamData was applied {replay_count} times: {:?}", + String::from_utf8_lossy(&seen) + ); +} + #[test] fn stale_packets_after_resume_are_ignored_not_fatal() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index 0085b14..77af004 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -1495,6 +1495,69 @@ fn resume_updates_udp_endpoint_for_roaming() { ); } +#[test] +#[ignore = "30-minute launch soak; run with `DOSH_SOAK_SECONDS=1800 cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture`"] +fn sleep_roaming_soak_30m() { + let soak_secs = std::env::var("DOSH_SOAK_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(30 * 60); + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config_with_timeout(&dir, port, soak_secs + 60); + let mut server = start_server(&dir, &config); + let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + thread::sleep(Duration::from_secs(soak_secs)); + + let resumed = UdpSocket::bind("127.0.0.1:0").unwrap(); + resumed + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let resume = ResumeRequest { + session: "default".to_string(), + last_rendered_seq: ok.initial_seq, + cols: 80, + rows: 24, + }; + send_encrypted( + &resumed, + port, + PacketKind::ResumeRequest, + ok.client_id, + 1, + 0, + &bootstrap.session_key, + &protocol::to_body(&resume).unwrap(), + ); + let (_header, resume_frame) = + recv_frame(&resumed, &bootstrap.session_key).expect("resume response after soak"); + assert!(resume_frame.snapshot); + + send_encrypted( + &resumed, + port, + PacketKind::Input, + ok.client_id, + 2, + resume_frame.output_seq, + &bootstrap.session_key, + &protocol::to_body(&Input { + bytes: b"printf DOSH_SOAK_OK\\n\n".to_vec(), + }) + .unwrap(), + ); + let text = collect_frame_text(&resumed, &bootstrap.session_key, 3000); + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + text.contains("DOSH_SOAK_OK"), + "session did not survive {soak_secs}s idle/roaming soak; output={text:?}" + ); +} + #[test] fn transport_rekey_round_trip_keeps_session_alive() { use dosh::native::derive_rekey_session_key;