From 25d9a6aefac4b939d0edab3048fb943ac588c768 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:29:06 -0400 Subject: [PATCH] Expand native auth and benchmark gates --- Cargo.lock | 4 + Cargo.toml | 4 +- README.md | 43 ++-- docs/BENCHMARKS.md | 66 ++++-- docs/NATIVE_V1_SPEC.md | 27 ++- docs/PUBLIC_READINESS.md | 75 ++++--- docs/THREAT_MODEL.md | 51 ++--- scripts/ci-docker-ssh-bench.sh | 28 ++- src/bin/dosh-client.rs | 39 ++-- src/bin/dosh-server.rs | 96 ++++++++- src/native.rs | 366 ++++++++++++++++++++++++++++++--- src/ssh_agent.rs | 80 +++++-- tests/integration_smoke.rs | 335 +++++++++++++++++++++++++++++- 13 files changed, 1036 insertions(+), 178 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5a3732..55dc1a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -452,8 +453,10 @@ dependencies = [ "portable-pty", "rand", "rpassword", + "rsa", "serde", "sha2", + "signature", "ssh-key", "tempfile", "tokio", @@ -1499,6 +1502,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", + "num-bigint-dig", "p256", "p384", "p521", diff --git a/Cargo.toml b/Cargo.toml index 60fcdd4..6b2b314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,12 @@ hmac = "0.12" libc = "0.2" portable-pty = "0.8" rand = "0.8" +rsa = { version = "0.9.10", features = ["sha2"] } rpassword = "7.5.4" serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" -ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] } +signature = "2.2" +ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] } tokio = { version = "1.41", features = ["full"] } toml = "0.8" vt100 = "0.15" diff --git a/README.md b/README.md index 0c8b554..cb0401c 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ dosh-server client table per session encrypted UDP protocol tiny SSH-invoked dosh-auth helper mode - persistent sessions (persist_sessions, default on): each shell runs in a + persistent sessions (persist_sessions, currently opt-in): each shell runs in a detached per-session holder process; the server adopts its PTY master fd via SCM_RIGHTS and re-adopts it after a restart, so sessions survive crash/upgrade @@ -197,9 +197,9 @@ Benchmark the ControlMaster-backed SSH bootstrap path: target/release/dosh-bench --server user@host --controlmaster --iterations 3 ``` -Run the Docker OpenSSH benchmark gate used by CI. It checks both cold SSH bootstrap -and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident -`dosh-server`: +Run the Docker OpenSSH benchmark gate used by CI. It checks cold SSH bootstrap, +ControlMaster-backed SSH bootstrap, native cold auth after one-time `dosh trust`, +and cached attach against a containerized `sshd` plus resident `dosh-server`: ```bash make bench-docker-ssh @@ -211,12 +211,12 @@ Run the same Docker comparison with Mosh installed in the benchmark container: make bench-docker-mosh ``` -That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same -container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which -is the real Dosh fast path after the first SSH-authenticated bootstrap 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. +That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`, +`dosh_cached_attach_ms`, and, for the Mosh target, `mosh_start_true_ms` under the +same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh +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. 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 @@ -281,10 +281,10 @@ Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substanti 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). +- **Native UDP auth** with X25519 key exchange; transcript-bound Ed25519, ECDSA + P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files; + 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 @@ -303,10 +303,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**. 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. +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. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 4f746b0..a6a1531 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -93,11 +93,13 @@ Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1 ### `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. +`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster SSH-bootstrap +`dosh_attach_ms`, one-time `dosh trust` followed by native-cold +`dosh_cold_native_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 SSH-bootstrap Dosh stays within +500 ms of SSH, native-cold Dosh beats SSH mean by default, and cached attach stays +under a small budget. See `README.md` "Develop" for the exact invocations. ## Methodology and caveats @@ -130,8 +132,8 @@ budget. See `README.md` "Develop" for the exact invocations. ## Sample results (loopback, self-contained) -Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in -milliseconds, 30 samples per metric. +Captured with `make bench-local` (`scripts/bench-local.sh 20`), all times in +milliseconds, 20 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 @@ -141,27 +143,24 @@ milliseconds, 30 samples per metric. | 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 | +| `dosh_cold_native_ms` | 20 | 8.32 | 8.77 | 9.34 | 8.82 | 9.52 | +| `dosh_cached_attach_ms` | 20 | 2.83 | 3.23 | 3.75 | 3.24 | 3.77 | +| `dosh_local_attach_ms` | 20 | 2.84 | 3.21 | 3.96 | 3.28 | 3.98 | 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 +8.82, 8.60, 8.69, 8.71, 9.33, 8.82, 8.90, 8.64, 8.39, 8.75, 8.79, 8.70, 8.64, +9.52, 8.72, 8.32, 9.06, 8.93, 8.96, 9.04 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 +3.04, 3.06, 2.98, 2.91, 2.96, 2.95, 3.05, 3.66, 3.37, 2.88, 2.83, 3.16, 3.49, +3.30, 3.77, 3.32, 3.49, 3.75, 3.56, 3.33 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 +3.08, 3.96, 3.36, 3.30, 2.88, 2.99, 3.96, 3.98, 3.25, 2.84, 3.29, 3.59, 3.65, +3.47, 3.13, 2.92, 2.87, 2.91, 2.98, 3.18 ``` Reading these numbers: cold native auth is ~9 ms because it pays the native @@ -171,3 +170,32 @@ overhead floor. Over a real link, expect cached attach ≈ this floor + one netw 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. + +## Sample results (Docker SSH/Mosh comparison) + +Captured with `make bench-docker-mosh`, all times in milliseconds. This is the +same-container comparison gate: one generated key, one OpenSSH server, one +`dosh-server`, loopback-published TCP/UDP ports. + +| Metric | n | min | median | p95 | mean | max | +| --- | --- | --- | --- | --- | --- | --- | +| `ssh_true_ms` (cold SSH baseline for native-cold gate) | 5 | 214.53 | 216.84 | 220.55 | 217.60 | 220.91 | +| `dosh_cold_native_ms` | 5 | 8.62 | 8.96 | 9.78 | 9.15 | 9.88 | +| `dosh_cached_attach_ms` | 10 | 8.41 | 8.83 | 9.52 | 8.90 | 9.53 | +| `mosh_start_true_ms` | 3 | 528.03 | 530.87 | 539.23 | 533.02 | 540.16 | + +Raw samples (ms): + +``` +ssh_true_ms (native-cold gate): +216.84, 216.61, 214.53, 220.91, 219.09 + +dosh_cold_native_ms: +9.39, 8.88, 8.96, 8.62, 9.88 + +dosh_cached_attach_ms: +8.44, 8.41, 8.80, 8.48, 9.53, 9.51, 8.85, 8.69, 9.33, 8.96 + +mosh_start_true_ms: +540.16, 528.03, 530.87 +``` diff --git a/docs/NATIVE_V1_SPEC.md b/docs/NATIVE_V1_SPEC.md index dcb8e47..2f8c5ab 100644 --- a/docs/NATIVE_V1_SPEC.md +++ b/docs/NATIVE_V1_SPEC.md @@ -7,16 +7,22 @@ > - 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). +> `AuthOk`, ssh-agent and OpenSSH identity-file auth for Ed25519, ECDSA P-256, +> and RSA-SHA2, plus `authorized_keys` verification, exist. > - 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`). +> 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 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`). > - 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. @@ -124,7 +130,8 @@ Native v1 must be boring under real use: - A closed laptop must not kill the remote session. - A client crash must not kill the remote session. - A server restart must not kill the remote session when `persist_sessions` is on - (the default): each session's shell runs in a detached per-session *holder* + (currently opt-in until stress-tested): each session's shell runs in a detached + per-session *holder* process whose PTY master fd the server passes back to itself via SCM_RIGHTS, so the shell + scrollback survive a server crash/upgrade/`systemctl restart` and a reattaching client lands on the same shell with its screen restored. With diff --git a/docs/PUBLIC_READINESS.md b/docs/PUBLIC_READINESS.md index 1107f11..21ec022 100644 --- a/docs/PUBLIC_READINESS.md +++ b/docs/PUBLIC_READINESS.md @@ -8,14 +8,14 @@ The plan for replacing the day-to-day SSH workflow with native Dosh authenticati 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. +`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. +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 @@ -58,31 +58,31 @@ 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 | +| Native UDP key auth (no SSH per attach) | no | yes, Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH identity files | 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 | +| 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 | | 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 | | Fresh session by default | yes | yes | ready | -| Named persistent sessions | no built-in shared session model | yes | ready | -| Multiple clients on one session | no | yes | needs conflict-policy docs | +| Named persistent sessions | no built-in shared session model | yes, plus opt-in server-restart holders | implemented; holder mode needs stress-testing | +| Multiple clients on one session | no | yes | implemented; needs conflict-policy docs | | View-only clients | no | yes | ready | -| Full-screen TUI correctness | yes | improving | must stay green before public push | +| Full-screen TUI correctness | yes | yes, scripted transport coverage for control sequences | implemented; needs broader app matrix | | Predictive local echo | mature | guarded printable-only opt-in | not parity | -| Non-destructive disconnect UI | yes | not currently | needed | +| Non-destructive disconnect UI | yes | yes, bottom-row save/restore status line | implemented | | 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 | 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 | +| 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 | | 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 | +| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests | ## SSH Config Inheritance @@ -131,7 +131,8 @@ Implemented: 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. +- More real-host load soak. The integration suite already covers terminal-priority + behavior while a local forward is under blocked-stream pressure. ## Native v1 Verification Checklist Status @@ -145,15 +146,17 @@ from code; in progress = partially implemented; pending = not yet implemented. | 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. | +| Native ECDSA P-256 auth | done | ssh-agent and OpenSSH identity-file paths are wired; `native_user_auth_accepts_ecdsa_p256_private_key` verifies the direct key path. | +| Native RSA-SHA2 auth | done | ssh-agent requests `rsa-sha2-512`; direct OpenSSH RSA identities sign with `rsa-sha2-512`; legacy SHA-1 `ssh-rsa` signatures are rejected. | | 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. | +| Native cold auth beats cold `ssh host true` | done | Docker gate runs `dosh-bench --cold-native` after one-time `dosh trust` and requires Dosh mean <= SSH mean. | +| Cached attach near network RTT | done | Local loopback samples are ~3 ms; Docker cached gate is under 25 ms. See `docs/BENCHMARKS.md`. | +| `-L` works without delaying terminal input | done | Per-stream windowing plus `native_local_forward_bulk_load_does_not_delay_interactive_terminal` and blocked-stream priority regression. | | `-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. | @@ -161,26 +164,30 @@ from code; in progress = partially implemented; pending = not yet implemented. | `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. | +| 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. | | 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. +- 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. +- 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. ## Before Public Launch - Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green. -- Add a non-destructive disconnect indicator. -- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode, - bracketed paste, and terminal cleanup. +- 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. -- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI. +- Stress-test `persist_sessions = true` before making restart-survivable holders the + default. +- 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 c83dce6..471b195 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -72,13 +72,13 @@ compromised endpoint or a malicious authorized peer. | 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. | +| User authentication by private-key possession | yes | yes | Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or 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. | +| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/signature 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. | @@ -94,8 +94,9 @@ compromised endpoint or a malicious authorized peer. - **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. +- **Modern primitives only.** Ed25519 and X25519 by default; ECDSA P-256 and + RSA-SHA2 are accepted for SSH-key compatibility; 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. @@ -114,9 +115,12 @@ These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and 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. +- **User-auth signatures:** Ed25519, ECDSA P-256, and RSA-SHA2, 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. RSA public keys are matched as `ssh-rsa` authorized keys, but + native signatures must be `rsa-sha2-256` or `rsa-sha2-512`; legacy SHA-1 + `ssh-rsa` signatures are rejected. - **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. @@ -179,26 +183,25 @@ be evaluated honestly. Items here are *not* yet "green". ### 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 +- **Native-auth rate limiting needs tuning, not first implementation.** The server + enforces a per-source token bucket before expensive native-auth work, evicts + 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 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. +- **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. +- **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 diff --git a/scripts/ci-docker-ssh-bench.sh b/scripts/ci-docker-ssh-bench.sh index ce099e2..fcd5e70 100755 --- a/scripts/ci-docker-ssh-bench.sh +++ b/scripts/ci-docker-ssh-bench.sh @@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do done ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts" -mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh" +mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh" if ! HOME="$workdir/home" target/release/dosh-bench \ --server bench@127.0.0.1 \ @@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \ exit 1 fi +if ! HOME="$workdir/home-native" target/release/dosh-client \ + --ssh-port "$ssh_port" \ + --ssh-key "$workdir/id_ed25519" \ + --ssh-known-hosts "$workdir/known_hosts" \ + --ssh-auth-command /usr/local/bin/dosh-auth \ + trust bench@127.0.0.1 >/dev/null; then + docker logs "$container_id" || true + docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true + exit 1 +fi + +if ! HOME="$workdir/home-native" target/release/dosh-bench \ + --server bench@127.0.0.1 \ + --ssh-port "$ssh_port" \ + --dosh-port "$dosh_port" \ + --dosh-host 127.0.0.1 \ + --ssh-key "$workdir/id_ed25519" \ + --ssh-known-hosts "$workdir/known_hosts" \ + --iterations 5 \ + --cold-native \ + --assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then + docker logs "$container_id" || true + docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true + exit 1 +fi + if ! HOME="$workdir/home-cached" target/release/dosh-bench \ --server bench@127.0.0.1 \ --ssh-port "$ssh_port" \ diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index f022f85..480a8f2 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -10,9 +10,10 @@ use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_ser use dosh::crypto; use dosh::native::{ EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, TrustResult, - derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_ed25519_identity, - load_ed25519_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host, - sign_user_auth, trust_host, verify_known_host, verify_server_hello, + derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_native_identity, + load_native_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host, + sign_user_auth_with_private_key, supported_user_key_algorithms, trust_host, verify_known_host, + verify_server_hello, }; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, @@ -1486,7 +1487,7 @@ async fn try_native_auth( requested_mode: mode.to_string(), terminal_size: (cols, rows), supported_aead: vec!["chacha20poly1305".to_string()], - supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], + supported_user_key_algorithms: supported_user_key_algorithms(), cached_host_key_fingerprint: None, attach_ticket_envelope: None, requested_env, @@ -1642,7 +1643,7 @@ async fn try_native_auth_check( requested_mode: "doctor".to_string(), terminal_size: (80, 24), supported_aead: vec!["chacha20poly1305".to_string()], - supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], + supported_user_key_algorithms: supported_user_key_algorithms(), cached_host_key_fingerprint: None, attach_ticket_envelope: None, requested_env: Vec::new(), @@ -1766,7 +1767,9 @@ fn sign_native_user_auth( } match load_first_native_identity(config, cli_identity, ssh_config) { - Ok(identity) => sign_user_auth(&identity, hello, server_hello, requested_forwardings), + Ok(identity) => { + sign_user_auth_with_private_key(&identity, hello, server_hello, requested_forwardings) + } Err(err) => { errors.push(format!("identity files: {err:#}")); Err(anyhow!( @@ -1781,7 +1784,7 @@ fn load_first_native_identity( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, ssh_config: &SshConfig, -) -> Result { +) -> Result { load_first_native_identity_with_prompt( config, cli_identity, @@ -1795,7 +1798,7 @@ fn load_first_native_identity_with_prompt( cli_identity: Option<&Path>, ssh_config: &SshConfig, mut prompt: F, -) -> Result +) -> Result where F: FnMut(&Path) -> Result, { @@ -1817,11 +1820,11 @@ where if !path.exists() { continue; } - match load_ed25519_identity(&path) { + match load_native_identity(&path) { Ok(identity) => return Ok(identity), Err(err) if err.to_string().contains(" is encrypted") => { match prompt(&path).and_then(|passphrase| { - load_ed25519_identity_with_passphrase(&path, Some(&passphrase)) + load_native_identity_with_passphrase(&path, Some(&passphrase)) }) { Ok(identity) => return Ok(identity), Err(err) => errors.push(format!("{}: {err:#}", path.display())), @@ -1831,10 +1834,10 @@ where } } if errors.is_empty() { - Err(anyhow!("no usable ssh-ed25519 identity found")) + Err(anyhow!("no usable native identity found")) } else { Err(anyhow!( - "no usable ssh-ed25519 identity found: {}", + "no usable native identity found: {}", errors.join("; ") )) } @@ -4222,8 +4225,8 @@ mod tests { .unwrap(); assert_eq!( - VerifyingKey::from(&loaded).to_bytes(), - VerifyingKey::from(&signing).to_bytes() + loaded.public_key().key_data().ed25519().unwrap().as_ref(), + VerifyingKey::from(&signing).as_bytes() ); } @@ -4243,7 +4246,7 @@ mod tests { ) .unwrap_err(); - assert!(err.to_string().contains("no usable ssh-ed25519 identity")); + assert!(err.to_string().contains("no usable native identity")); } #[test] @@ -4263,7 +4266,7 @@ mod tests { }) .unwrap_err(); - assert!(err.to_string().contains("no usable ssh-ed25519 identity")); + assert!(err.to_string().contains("no usable native identity")); } #[test] @@ -4285,8 +4288,8 @@ mod tests { .unwrap(); assert_eq!( - VerifyingKey::from(&loaded).to_bytes(), - VerifyingKey::from(&signing).to_bytes() + loaded.public_key().key_data().ed25519().unwrap().as_ref(), + VerifyingKey::from(&signing).as_bytes() ); } diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 65a3583..fa9bae1 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -878,9 +878,11 @@ async fn handle_native_client_hello( .hello .supported_user_key_algorithms .iter() - .any(|algorithm| algorithm == "ssh-ed25519") + .any(|algorithm| dosh::native::is_supported_user_signature_algorithm(algorithm)) { - Err(anyhow!("native auth requires ssh-ed25519")) + Err(anyhow!( + "native auth requires a supported user key algorithm" + )) } else { let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); if req.hello.requested_user != current_user { @@ -3266,6 +3268,96 @@ mod tests { assert_eq!(data.bytes, b"hello"); } + #[tokio::test] + async fn blocked_stream_data_does_not_block_terminal_frames() { + let (pty_tx, _pty_rx) = mpsc::unbounded_channel(); + let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx); + let client_id = [8u8; 16]; + let session_key = [10u8; 32]; + let receiver = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let sender = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); + let endpoint = receiver.local_addr().unwrap(); + state.sessions.insert( + "test".to_string(), + Session { + pty: None, + parser: vt100::Parser::new(24, 80, 100), + clients: HashMap::from([( + client_id, + ClientState { + endpoint, + mode: "read-write".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::from([42]), + stream_send_credit: HashMap::from([(42, 0)]), + stream_pending_data: HashMap::new(), + epoch: 0, + epoch_started: Instant::now(), + epoch_packets: 0, + previous_session_key: None, + }, + )]), + output_seq: 0, + recent: VecDeque::new(), + empty_since: None, + holder_control: None, + persistent: false, + bytes_since_persist: 0, + last_persisted_seq: 0, + }, + ); + 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"blocked-forward-bytes".to_vec(), + ) + .await + .unwrap(); + { + let locked = state.lock().unwrap(); + let client = locked.sessions["test"].clients.get(&client_id).unwrap(); + assert_eq!(client.stream_pending_data[&42].len(), 1); + assert_eq!(client.stream_send_credit[&42], 0); + } + + broadcast_output( + &state, + &sender, + PtyOutput { + session: "test".to_string(), + bytes: b"terminal-priority".to_vec(), + exited: false, + }, + ) + .await + .unwrap(); + + let mut buf = [0u8; 2048]; + let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf)) + .await + .unwrap() + .unwrap(); + let packet = protocol::decode(&buf[..n]).unwrap(); + assert_eq!(packet.header.kind, PacketKind::Frame); + let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap(); + let frame: Frame = protocol::from_body(&plain).unwrap(); + assert_eq!(frame.bytes, b"terminal-priority"); + } + #[test] fn remote_non_loopback_bind_requires_explicit_config() { let config = ServerConfig::default(); diff --git a/src/native.rs b/src/native.rs index eb8f3c7..185a92a 100644 --- a/src/native.rs +++ b/src/native.rs @@ -5,7 +5,9 @@ use anyhow::{Context, Result, bail}; use base64::Engine; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rsa::traits::PublicKeyParts; use serde::{Deserialize, Serialize}; +use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier}; use std::fs; use std::io::Write; use std::net::IpAddr; @@ -339,6 +341,92 @@ pub fn sign_user_auth( Ok(auth) } +pub fn sign_user_auth_with_private_key( + private_key: &ssh_key::PrivateKey, + client: &NativeClientHello, + server: &NativeServerHello, + requested_forwardings: Vec, +) -> Result { + anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted"); + let public_key = private_key.public_key(); + let key_algorithm = public_key.algorithm().as_str().to_string(); + anyhow::ensure!( + is_supported_user_key_algorithm(&key_algorithm), + "unsupported native identity key algorithm {key_algorithm}" + ); + let public_key_blob = ssh_public_blob_from_public_key(public_key)?; + let public_key_for_auth = if key_algorithm == "ssh-ed25519" { + parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec() + } else { + public_key_blob + }; + let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?; + let mut auth = NativeUserAuth { + public_key_algorithm: signature_algorithm.to_string(), + public_key: public_key_for_auth, + signature: Vec::new(), + requested_forwardings, + }; + let transcript = user_auth_transcript(client, server, &auth)?; + auth.signature = if key_algorithm == "ssh-rsa" { + sign_rsa_sha512_private_key(private_key, &transcript)? + } else { + let signature = SignatureSigner::try_sign(private_key, &transcript) + .context("sign native user auth with OpenSSH private key")?; + anyhow::ensure!( + signature.algorithm().as_str() == auth.public_key_algorithm, + "private key produced signature algorithm {}, expected {}", + signature.algorithm().as_str(), + auth.public_key_algorithm + ); + signature.as_bytes().to_vec() + }; + Ok(auth) +} + +fn sign_rsa_sha512_private_key( + private_key: &ssh_key::PrivateKey, + transcript: &[u8], +) -> Result> { + let rsa_keypair = private_key + .key_data() + .rsa() + .ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?; + let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?; + let signing_key = rsa::pkcs1v15::SigningKey::::new(rsa_private); + let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript); + Ok(signature.to_vec()) +} + +fn rsa_private_key_from_ssh_keypair( + keypair: &ssh_key::private::RsaKeypair, +) -> Result { + let private = rsa::RsaPrivateKey::from_components( + rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?, + rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?, + rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?, + vec![ + rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?, + rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?, + ], + ) + .context("convert OpenSSH RSA private key")?; + anyhow::ensure!( + private.size().saturating_mul(8) >= 2048, + "OpenSSH RSA identity is smaller than 2048 bits" + ); + Ok(private) +} + +fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> { + match key_algorithm { + "ssh-ed25519" => Ok("ssh-ed25519"), + "ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"), + "ssh-rsa" => Ok("rsa-sha2-512"), + _ => bail!("unsupported native identity key algorithm {key_algorithm}"), + } +} + pub fn verify_native_user_auth( client: &NativeClientHello, server: &NativeServerHello, @@ -347,37 +435,127 @@ pub fn verify_native_user_auth( source_ip: Option, ) -> Result { anyhow::ensure!( - auth.public_key_algorithm == "ssh-ed25519", + is_supported_user_signature_algorithm(&auth.public_key_algorithm), "unsupported native user key algorithm {}", auth.public_key_algorithm ); - anyhow::ensure!( - auth.public_key.len() == 32, - "ssh-ed25519 public key must be 32 bytes" - ); - anyhow::ensure!( - auth.signature.len() == 64, - "ssh-ed25519 signature must be 64 bytes" - ); + let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?; let authorized = authorized_keys .iter() - .find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key) + .find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key) .ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?; authorized.ensure_native_allowed(auth, source_ip)?; - let mut public_key = [0u8; 32]; - public_key.copy_from_slice(&auth.public_key); - let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?; - let mut signature = [0u8; 64]; - signature.copy_from_slice(&auth.signature); - let signature = Signature::from_bytes(&signature); let transcript = user_auth_transcript(client, server, auth)?; - verifying_key - .verify(&transcript, &signature) - .context("verify native user signature")?; + verify_native_user_signature(auth, &transcript)?; Ok(authorized.clone()) } +pub fn supported_user_key_algorithms() -> Vec { + vec![ + "ssh-ed25519".to_string(), + "ecdsa-sha2-nistp256".to_string(), + "rsa-sha2-512".to_string(), + "rsa-sha2-256".to_string(), + ] +} + +pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool { + matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa") +} + +pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool { + matches!( + algorithm, + "ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256" + ) +} + +fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> { + match signature_algorithm { + "ssh-ed25519" => Ok("ssh-ed25519"), + "ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"), + "rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"), + _ => bail!("unsupported native user key algorithm {signature_algorithm}"), + } +} + +fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> { + match auth.public_key_algorithm.as_str() { + "ssh-ed25519" => { + anyhow::ensure!( + auth.public_key.len() == 32, + "ssh-ed25519 public key must be 32 bytes" + ); + anyhow::ensure!( + auth.signature.len() == 64, + "ssh-ed25519 signature must be 64 bytes" + ); + let mut public_key = [0u8; 32]; + public_key.copy_from_slice(&auth.public_key); + let verifying_key = + VerifyingKey::from_bytes(&public_key).context("parse user public key")?; + let mut signature = [0u8; 64]; + signature.copy_from_slice(&auth.signature); + let signature = Signature::from_bytes(&signature); + verifying_key + .verify(transcript, &signature) + .context("verify native user signature")?; + } + "ecdsa-sha2-nistp256" => { + let public_key = ssh_public_key_from_blob(&auth.public_key) + .context("parse native user SSH public key")?; + let algorithm = + ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| { + format!("parse signature algorithm {}", auth.public_key_algorithm) + })?; + let signature = ssh_key::Signature::new(algorithm, auth.signature.clone()) + .context("parse native user SSH signature")?; + SignatureVerifier::verify(public_key.key_data(), transcript, &signature) + .context("verify native user SSH signature")?; + } + "rsa-sha2-512" | "rsa-sha2-256" => { + verify_rsa_sha2_signature( + &auth.public_key, + &auth.public_key_algorithm, + transcript, + &auth.signature, + )?; + } + algorithm => bail!("unsupported native user key algorithm {algorithm}"), + } + Ok(()) +} + +fn verify_rsa_sha2_signature( + public_key_blob: &[u8], + signature_algorithm: &str, + transcript: &[u8], + signature: &[u8], +) -> Result<()> { + let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?; + let public = rsa::RsaPublicKey::new( + rsa::BigUint::from_bytes_be(n), + rsa::BigUint::from_bytes_be(e), + ) + .context("parse RSA public key")?; + let signature = + rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?; + match signature_algorithm { + "rsa-sha2-256" => { + let key = rsa::pkcs1v15::VerifyingKey::::new(public); + SignatureVerifier::verify(&key, transcript, &signature) + .context("verify RSA-SHA256 signature") + } + "rsa-sha2-512" => { + let key = rsa::pkcs1v15::VerifyingKey::::new(public); + SignatureVerifier::verify(&key, transcript, &signature) + .context("verify RSA-SHA512 signature") + } + _ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"), + } +} + pub fn verify_native_user_auth_from_config( config: &ServerConfig, client: &NativeClientHello, @@ -470,6 +648,35 @@ pub fn load_ed25519_identity_with_passphrase( .with_context(|| format!("parse ssh-ed25519 identity {}", path.display())) } +pub fn load_native_identity(path: &Path) -> Result { + load_native_identity_with_passphrase(path, None) +} + +pub fn load_native_identity_with_passphrase( + path: &Path, + passphrase: Option<&str>, +) -> Result { + let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let private_key = ssh_key::PrivateKey::from_openssh(&raw) + .with_context(|| format!("parse OpenSSH identity {}", path.display()))?; + let private_key = if private_key.is_encrypted() { + let passphrase = passphrase + .ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?; + private_key + .decrypt(passphrase) + .with_context(|| format!("decrypt OpenSSH identity {}", path.display()))? + } else { + private_key + }; + let algorithm = private_key.public_key().algorithm().as_str().to_string(); + anyhow::ensure!( + is_supported_user_key_algorithm(&algorithm), + "OpenSSH identity {} has unsupported native key algorithm {algorithm}", + path.display() + ); + Ok(private_key) +} + impl AuthorizedKey { fn ensure_native_allowed( &self, @@ -565,7 +772,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result= 2, "authorized_keys:{line_number}: expected key fields" ); - let (options, key_type_index) = if fields[0].starts_with("ssh-") { + let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) { (AuthorizedKeyOptions::default(), 0) } else { (parse_authorized_key_options(&fields[0])?, 1) @@ -576,15 +783,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result key_type_index + 2 { Some(fields[key_type_index + 2..].join(" ")) } else { @@ -598,6 +810,51 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result Result<()> { + let blob_algorithm = ssh_public_key_blob_algorithm(blob)?; + anyhow::ensure!( + blob_algorithm == algorithm, + "key blob type {blob_algorithm} does not match authorized_keys type {algorithm}" + ); + match algorithm { + "ssh-ed25519" => { + parse_ssh_ed25519_public_blob(blob)?; + } + "ecdsa-sha2-nistp256" | "ssh-rsa" => { + let _ = ssh_public_key_from_blob(blob)?; + } + _ => bail!("unsupported key type {algorithm}"), + } + Ok(()) +} + +fn ssh_public_key_blob_algorithm(blob: &[u8]) -> Result { + let mut cursor = blob; + let algorithm = read_ssh_string(&mut cursor)?; + Ok(String::from_utf8_lossy(algorithm).to_string()) +} + +fn ssh_public_key_from_blob(blob: &[u8]) -> Result { + let algorithm = ssh_public_key_blob_algorithm(blob)?; + let encoded = STANDARD.encode(blob); + ssh_key::PublicKey::from_openssh(&format!("{algorithm} {encoded}")) + .with_context(|| format!("parse OpenSSH public key blob {algorithm}")) +} + +fn ssh_public_blob_from_public_key(public_key: &ssh_key::PublicKey) -> Result> { + let line = public_key + .to_openssh() + .context("encode OpenSSH public key")?; + let mut fields = line.split_whitespace(); + let _algorithm = fields + .next() + .context("encoded public key missing algorithm")?; + let encoded = fields.next().context("encoded public key missing blob")?; + STANDARD + .decode(encoded) + .context("decode encoded OpenSSH public key blob") +} + fn split_authorized_key_fields(line: &str) -> Vec { line.split_whitespace().map(ToString::to_string).collect() } @@ -765,6 +1022,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> { Ok(out) } +fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> { + let mut cursor = blob; + let key_type = read_ssh_string(&mut cursor)?; + anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch"); + let e = read_ssh_mpint(&mut cursor)?; + let n = read_ssh_mpint(&mut cursor)?; + anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob"); + anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty"); + anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty"); + Ok((e, n)) +} + pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec { let mut out = Vec::new(); write_ssh_string(&mut out, b"ssh-ed25519"); @@ -772,6 +1041,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec { out } +fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { + let raw = read_ssh_string(cursor)?; + let raw = raw.strip_prefix(&[0]).unwrap_or(raw); + Ok(raw) +} + fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length"); let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize; @@ -1084,6 +1359,45 @@ mod tests { assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err()); } + #[test] + fn native_user_auth_accepts_ecdsa_p256_private_key() { + let mut rng = rand::rngs::OsRng; + let private = ssh_key::PrivateKey::random( + &mut rng, + ssh_key::Algorithm::Ecdsa { + curve: ssh_key::EcdsaCurve::NistP256, + }, + ) + .unwrap(); + let host_signing = SigningKey::from_bytes(&[10u8; 32]); + let client = test_client_hello(); + let mut server = test_server_hello(&host_signing); + sign_server_hello(&host_signing, &client, &mut server).unwrap(); + let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap(); + let authorized = + parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap(); + + assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256"); + verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap(); + } + + #[test] + fn native_user_auth_accepts_rsa_sha2_private_key() { + let mut rng = rand::rngs::OsRng; + let private = + ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap(); + let host_signing = SigningKey::from_bytes(&[11u8; 32]); + let client = test_client_hello(); + let mut server = test_server_hello(&host_signing); + sign_server_hello(&host_signing, &client, &mut server).unwrap(); + let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap(); + let authorized = + parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap(); + + assert_eq!(auth.public_key_algorithm, "rsa-sha2-512"); + verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap(); + } + #[test] fn native_user_auth_rejects_tampered_transcript() { let host_signing = SigningKey::from_bytes(&[3u8; 32]); diff --git a/src/ssh_agent.rs b/src/ssh_agent.rs index 779f9ca..1b7389f 100644 --- a/src/ssh_agent.rs +++ b/src/ssh_agent.rs @@ -1,6 +1,6 @@ use crate::native::{ ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth, - parse_ssh_ed25519_public_blob, user_auth_transcript, + is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript, }; use anyhow::{Context, Result, anyhow, bail}; use std::io::{Read, Write}; @@ -12,12 +12,16 @@ const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11; const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12; const SSH2_AGENTC_SIGN_REQUEST: u8 = 13; const SSH2_AGENT_SIGN_RESPONSE: u8 = 14; +const SSH_AGENT_RSA_SHA2_512: u32 = 4; const MAX_AGENT_PACKET: usize = 256 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentIdentity { pub key_blob: Vec, - pub public_key: [u8; 32], + pub public_key_algorithm: String, + pub public_key: Vec, + pub sign_algorithm: String, + pub sign_flags: u32, pub comment: String, } @@ -38,13 +42,13 @@ pub fn sign_user_auth_with_agent_at( ) -> Result { let mut agent = UnixStream::connect(socket_path.as_ref()) .with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?; - let identities = request_ed25519_identities(&mut agent)?; + let identities = request_supported_identities(&mut agent)?; let identity = identities .first() - .ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?; + .ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?; let mut auth = NativeUserAuth { - public_key_algorithm: "ssh-ed25519".to_string(), - public_key: identity.public_key.to_vec(), + public_key_algorithm: identity.sign_algorithm.clone(), + public_key: identity.public_key.clone(), signature: Vec::new(), requested_forwardings, }; @@ -54,7 +58,7 @@ pub fn sign_user_auth_with_agent_at( Ok(auth) } -fn request_ed25519_identities(agent: &mut UnixStream) -> Result> { +fn request_supported_identities(agent: &mut UnixStream) -> Result> { write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?; let payload = read_agent_packet(agent)?; let mut cursor = payload.as_slice(); @@ -72,18 +76,49 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result, comment: String) -> Result> { + let algorithm = key_blob_algorithm(&key_blob)?; + if !is_supported_user_key_algorithm(&algorithm) { + return Ok(None); + } + let identity = match algorithm.as_str() { + "ssh-ed25519" => AgentIdentity { + public_key_algorithm: algorithm.clone(), + public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(), + sign_algorithm: "ssh-ed25519".to_string(), + sign_flags: 0, + key_blob, + comment, + }, + "ecdsa-sha2-nistp256" => AgentIdentity { + public_key_algorithm: algorithm.clone(), + public_key: key_blob.clone(), + sign_algorithm: algorithm, + sign_flags: 0, + key_blob, + comment, + }, + "ssh-rsa" => AgentIdentity { + public_key_algorithm: algorithm, + public_key: key_blob.clone(), + sign_algorithm: "rsa-sha2-512".to_string(), + sign_flags: SSH_AGENT_RSA_SHA2_512, + key_blob, + comment, + }, + _ => return Ok(None), + }; + Ok(Some(identity)) +} + fn sign_with_agent( agent: &mut UnixStream, identity: &AgentIdentity, @@ -93,7 +128,7 @@ fn sign_with_agent( request.push(SSH2_AGENTC_SIGN_REQUEST); write_ssh_string(&mut request, &identity.key_blob); write_ssh_string(&mut request, transcript); - request.extend_from_slice(&0u32.to_be_bytes()); + request.extend_from_slice(&identity.sign_flags.to_be_bytes()); write_agent_packet(agent, &request)?; let payload = read_agent_packet(agent)?; @@ -114,20 +149,17 @@ fn sign_with_agent( let mut signature_cursor = signature_blob; let algorithm = read_ssh_string(&mut signature_cursor)?; + let algorithm = String::from_utf8_lossy(algorithm); anyhow::ensure!( - algorithm == b"ssh-ed25519", - "ssh-agent returned unsupported signature algorithm {}", - String::from_utf8_lossy(algorithm) + algorithm == identity.sign_algorithm, + "ssh-agent returned signature algorithm {algorithm}, expected {}", + identity.sign_algorithm ); let signature = read_ssh_string(&mut signature_cursor)?; anyhow::ensure!( signature_cursor.is_empty(), "trailing data in ssh-agent signature blob" ); - anyhow::ensure!( - signature.len() == 64, - "ssh-agent Ed25519 signature was not 64 bytes" - ); Ok(signature.to_vec()) } @@ -182,6 +214,12 @@ fn write_ssh_string(out: &mut Vec, value: &[u8]) { out.extend_from_slice(value); } +fn key_blob_algorithm(blob: &[u8]) -> Result { + let mut cursor = blob; + let algorithm = read_ssh_string(&mut cursor)?; + Ok(String::from_utf8_lossy(algorithm).to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index b8709d5..0085b14 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -4,6 +4,7 @@ use std::net::{TcpListener, TcpStream, UdpSocket}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; use std::thread; use std::time::Duration; @@ -234,6 +235,37 @@ fn start_echo_server() -> u16 { port } +fn start_slow_sink_server() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => thread::sleep(Duration::from_millis(20)), + } + } + } + }); + port +} + +fn connect_with_retry_no_child(port: u16, timeout: Duration) -> TcpStream { + let deadline = std::time::Instant::now() + timeout; + loop { + match TcpStream::connect(("127.0.0.1", port)) { + Ok(stream) => return stream, + Err(err) if std::time::Instant::now() < deadline => { + let _ = err; + thread::sleep(Duration::from_millis(50)); + } + Err(err) => panic!("connect to local forward 127.0.0.1:{port}: {err}"), + } + } +} + fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) { write_native_client_auth_with_options(dir, config_path, ""); } @@ -414,6 +446,15 @@ fn direct_attach( config: &std::path::Path, port: u16, mode: &str, +) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) { + direct_attach_session(config, port, mode, "default") +} + +fn direct_attach_session( + config: &std::path::Path, + port: u16, + mode: &str, + session: &str, ) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) { let config = load_server_config(Some(config.to_path_buf())).unwrap(); let secret = load_or_create_server_secret(&config).unwrap(); @@ -421,7 +462,7 @@ fn direct_attach( &config, &secret, "tester".to_string(), - "default".to_string(), + session.to_string(), mode.to_string(), (80, 24), crypto::random_12(), @@ -782,6 +823,106 @@ fn native_local_forward_background_smoke() { assert_eq!(&buf[..n], b"dosh-background-forward-ping"); } +#[test] +fn native_local_forward_bulk_load_does_not_delay_interactive_terminal() { + use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; + + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + write_native_client_auth(&dir, &config); + let sink_port = start_slow_sink_server(); + let local_port = free_tcp_port(); + let mut server = start_server(&dir, &config); + + let pty = NativePtySystem::default(); + let pair = pty + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut writer = pair.master.take_writer().unwrap(); + + let client_bin = env!("CARGO_BIN_EXE_dosh-client"); + let mut cmd = CommandBuilder::new(client_bin); + let forward_arg = format!("{local_port}:127.0.0.1:{sink_port}"); + let dosh_port = port.to_string(); + cmd.args([ + "--auth", + "native", + "--no-cache", + "-L", + &forward_arg, + "--session", + "load-session", + "--dosh-host", + "127.0.0.1", + "--dosh-port", + &dosh_port, + "local", + ]); + cmd.env("HOME", dir.path().to_string_lossy().to_string()); + let mut child = pair.slave.spawn_command(cmd).unwrap(); + drop(pair.slave); + + let mut forward_stream = connect_with_retry_no_child(local_port, Duration::from_secs(5)); + let bulk_writer = thread::spawn(move || { + let chunk = vec![b'x'; 8192]; + for _ in 0..128 { + if forward_stream.write_all(&chunk).is_err() { + break; + } + } + }); + + let (seen_tx, seen_rx) = mpsc::channel(); + let reader_handle = thread::spawn(move || { + let mut buf = [0u8; 4096]; + let mut output = Vec::new(); + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + output.extend_from_slice(&buf[..n]); + if output + .windows(b"DOSH_LOAD_TERMINAL_OK".len()) + .any(|w| w == b"DOSH_LOAD_TERMINAL_OK") + { + let _ = seen_tx.send(String::from_utf8_lossy(&output).to_string()); + break; + } + } + } + } + }); + + thread::sleep(Duration::from_millis(250)); + writer + .write_all(b"printf DOSH_LOAD_TERMINAL_OK\\n\r") + .unwrap(); + writer.flush().unwrap(); + + let seen = seen_rx.recv_timeout(Duration::from_secs(3)); + + let _ = child.kill(); + let _ = child.wait(); + drop(writer); + drop(pair.master); + let _ = reader_handle.join(); + let _ = bulk_writer.join(); + let _ = server.kill(); + let _ = server.wait(); + + assert!( + seen.is_ok(), + "interactive terminal output was delayed behind local-forward bulk load" + ); +} + #[test] fn native_remote_forward_echo_smoke() { let dir = tempfile::tempdir().unwrap(); @@ -1180,6 +1321,67 @@ fn live_output_forwards_terminal_control_sequences() { ); } +#[test] +fn tui_control_sequences_survive_transport_verbatim() { + 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 (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + let sequences = concat!( + "\x1b[?1049h", // alternate screen on + "\x1b[?2004h", // bracketed paste on + "\x1b[?1000h", // mouse tracking on + "\x1b[?1006h", // SGR mouse encoding on + "\x1b[?25l", // cursor hidden + "\x1b[12;34H", // absolute cursor movement + "DOSH_TUI_VERBATIM", + "\x1b[?25h", + "\x1b[?1006l", + "\x1b[?1000l", + "\x1b[?2004l", + "\x1b[?1049l" + ); + let input = Input { + bytes: format!("printf '{sequences}'\n").into_bytes(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + let text = collect_frame_text(&socket, &bootstrap.session_key, 2000); + + let _ = server.kill(); + let _ = server.wait(); + + for needle in [ + "\x1b[?1049h", + "\x1b[?2004h", + "\x1b[?1000h", + "\x1b[?1006h", + "\x1b[?25l", + "\x1b[12;34H", + "DOSH_TUI_VERBATIM", + "\x1b[?25h", + "\x1b[?1006l", + "\x1b[?1000l", + "\x1b[?2004l", + "\x1b[?1049l", + ] { + assert!( + text.contains(needle), + "expected TUI control sequence {needle:?} to survive verbatim, got {text:?}" + ); + } +} + #[test] fn resume_snapshot_preserves_alternate_screen_mode() { let dir = tempfile::tempdir().unwrap(); @@ -1983,3 +2185,134 @@ fn session_survives_server_restart_same_shell_and_screen() { "restored attach snapshot must repaint the pre-restart screen, got {snapshot:?}" ); } + +#[test] +fn multiple_persistent_named_sessions_survive_restart_independently() { + let dir = tempfile::tempdir().unwrap(); + let sessions_dir = dir.path().join("sessions"); + let port = free_udp_port(); + let config = write_persistent_server_config(&dir, port); + + let mut server = start_server(&dir, &config); + + let (work_socket, work_bootstrap, work_ok) = + direct_attach_session(&config, port, "read-write", "work"); + let (logs_socket, logs_bootstrap, logs_ok) = + direct_attach_session(&config, port, "read-write", "logs"); + let work_key = work_bootstrap.session_key; + let logs_key = logs_bootstrap.session_key; + + type_line( + &work_socket, + port, + &work_ok, + &work_key, + 2, + "export DOSH_SLOT=work_slot\n", + ); + type_line( + &logs_socket, + port, + &logs_ok, + &logs_key, + 2, + "export DOSH_SLOT=logs_slot\n", + ); + type_line( + &work_socket, + port, + &work_ok, + &work_key, + 3, + "printf 'WORK_SCREEN_MARKER\\n'\n", + ); + type_line( + &logs_socket, + port, + &logs_ok, + &logs_key, + 3, + "printf 'LOGS_SCREEN_MARKER\\n'\n", + ); + let work_pre = collect_frame_text(&work_socket, &work_key, 1500); + let logs_pre = collect_frame_text(&logs_socket, &logs_key, 1500); + assert!( + work_pre.contains("WORK_SCREEN_MARKER"), + "work marker missing before restart: {work_pre:?}" + ); + assert!( + logs_pre.contains("LOGS_SCREEN_MARKER"), + "logs marker missing before restart: {logs_pre:?}" + ); + thread::sleep(Duration::from_secs(3)); + + let work_pid_before = holder_shell_pid(&sessions_dir, "work"); + let logs_pid_before = holder_shell_pid(&sessions_dir, "logs"); + assert!(work_pid_before.is_some(), "work holder pid missing"); + assert!(logs_pid_before.is_some(), "logs holder pid missing"); + assert_ne!( + work_pid_before, logs_pid_before, + "named sessions must have independent holder shells" + ); + + let _ = server.kill(); + let _ = server.wait(); + thread::sleep(Duration::from_millis(300)); + + let mut server = start_server(&dir, &config); + let (work_socket2, work_bootstrap2, work_ok2) = + direct_attach_session(&config, port, "read-write", "work"); + let (logs_socket2, logs_bootstrap2, logs_ok2) = + direct_attach_session(&config, port, "read-write", "logs"); + let work_key2 = work_bootstrap2.session_key; + let logs_key2 = logs_bootstrap2.session_key; + let work_snapshot = String::from_utf8_lossy(&work_ok2.snapshot).to_string(); + let logs_snapshot = String::from_utf8_lossy(&logs_ok2.snapshot).to_string(); + + type_line( + &work_socket2, + port, + &work_ok2, + &work_key2, + 2, + "printf 'WORK_SLOT=%s\\n' \"$DOSH_SLOT\"\n", + ); + type_line( + &logs_socket2, + port, + &logs_ok2, + &logs_key2, + 2, + "printf 'LOGS_SLOT=%s\\n' \"$DOSH_SLOT\"\n", + ); + let work_post = collect_frame_text(&work_socket2, &work_key2, 2000); + let logs_post = collect_frame_text(&logs_socket2, &logs_key2, 2000); + let work_pid_after = holder_shell_pid(&sessions_dir, "work"); + let logs_pid_after = holder_shell_pid(&sessions_dir, "logs"); + + let _ = server.kill(); + let _ = server.wait(); + kill_holder(&sessions_dir, "work"); + kill_holder(&sessions_dir, "logs"); + + assert_eq!(work_pid_after, work_pid_before); + assert_eq!(logs_pid_after, logs_pid_before); + assert!( + work_snapshot.contains("WORK_SCREEN_MARKER") + && !work_snapshot.contains("LOGS_SCREEN_MARKER"), + "work snapshot should restore only work screen, got {work_snapshot:?}" + ); + assert!( + logs_snapshot.contains("LOGS_SCREEN_MARKER") + && !logs_snapshot.contains("WORK_SCREEN_MARKER"), + "logs snapshot should restore only logs screen, got {logs_snapshot:?}" + ); + assert!( + work_post.contains("WORK_SLOT=work_slot") && !work_post.contains("logs_slot"), + "work state crossed sessions or disappeared: {work_post:?}" + ); + assert!( + logs_post.contains("LOGS_SLOT=logs_slot") && !logs_post.contains("work_slot"), + "logs state crossed sessions or disappeared: {logs_post:?}" + ); +}