diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0d1c4f..8472232 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,17 @@ jobs: if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success' run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run." + windows-client: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Build Windows client + run: cargo build --release --bin dosh-client --bin dosh-bench + - name: Package Windows client + shell: bash + run: sh scripts/package-release.sh + package-release: if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') strategy: diff --git a/Makefile b/Makefile index 6fae638..7026533 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test fmt install package-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local +.PHONY: build test fmt install package-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness build: cargo build --release @@ -42,6 +42,9 @@ fuzz-smoke: fuzz-deep: DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh +tui-harness: + sh scripts/tui-harness.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 c414776..274c759 100644 --- a/README.md +++ b/README.md @@ -1,403 +1,154 @@ -# dosh - Dormant Shell - -dosh is a low-latency remote terminal designed around fast attach and fast reconnect. -It is mosh-shaped, but not a mosh clone: the server is a resident daemon, terminal -sessions stay hot, and repeat connects try encrypted UDP before starting SSH. - -The core target is simple: - -- First secure trust establishment uses SSH. -- Existing sessions attach in one encrypted UDP exchange whenever cached credentials allow it. -- Reconnect after sleep, roaming, or network change resumes in one encrypted UDP exchange. -- Cold SSH fallback stays competitive with plain `ssh` by doing less after auth. - -## Why not just mosh? - -mosh is excellent at roaming and high-latency interactivity. Its startup path still -has work dosh can avoid: - -1. SSH connects to the host. -2. SSH starts `mosh-server`. -3. The client receives connection material over SSH. -4. SSH exits and the mosh UDP session begins. - -dosh keeps `dosh-server` running before the client arrives. Named PTY sessions can -also be prewarmed, so attaching to `default` does not need to spawn a daemon, create -a PTY, or start a shell on the user's critical path. - -This is not an encryption argument against mosh. dosh also encrypts its UDP data -channel; the speed difference comes from keeping the server and session hot. - -## Fast Path Order - -The client always tries the cheapest valid path first: - -1. **UDP resume:** existing `ClientId` and session key. No SSH. One encrypted UDP - request, one encrypted UDP reply. -2. **UDP attach ticket:** cached server-issued attach ticket for the same - host/user/session/mode. No SSH. One encrypted UDP request, one encrypted UDP - reply. -3. **SSH bootstrap:** `ssh -T user@host ~/.local/bin/dosh-auth ...`, then one encrypted UDP - attach. -4. **New session:** same as attach, but the server must create the PTY/shell unless - the session was prewarmed. - -The fastest path is not a custom SSH replacement. SSH remains the first trust root; -dosh removes SSH from repeat attaches when the server has already issued valid -credentials. - -Attach tickets are implemented because they are the way a fresh client process can -skip SSH after a recent successful bootstrap. - -## Connection Speed Contract - -dosh is measured by terminal-ready time: elapsed time from running `dosh host` to the -first usable terminal screen. - -- UDP resume: <= one measured UDP RTT + local render time. -- UDP attach ticket: <= one measured UDP RTT + local render time. -- Warm attach with ControlMaster: <= `ssh host true` over the existing master + one - measured UDP RTT. -- Cold attach without ControlMaster: <= cold `ssh host` terminal-ready time + one - measured UDP RTT. -- New session: measured separately because it may need PTY and shell creation. - -Server-side client resume state is kept for a day by default, so a sleeping laptop -can resume the same encrypted client association without depending on a still-valid -attach ticket. Attach tickets remain the fast path for fresh client processes. - -The client emits timing spans for credential lookup, SSH bootstrap, UDP resume, -UDP ticket attach, and terminal-ready time. - -## Architecture - -```text -dosh-server - UDP socket on one configurable port - session table keyed by name - one PTY per named session - optional prewarmed sessions, default ["default"] - terminal parser/screen state per session - client table per session - encrypted UDP protocol - tiny SSH-invoked dosh-auth helper mode - 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 - -dosh-client - terminal raw mode - local credential cache - UDP resume/attach first - SSH bootstrap fallback - PTY input/output forwarding - reconnect and roaming state machine -``` - -## Install - -Default UDP port: `50000`. This is intentionally inside the common forwarded range -`50000-52000/udp`. - -Install on each Linux server you want to attach to: - -```bash -curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ - | DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- server -``` - -Install the client on macOS: - -```bash -curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ - | DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- client -``` - -Update an installed client later: - -```bash -dosh update -dosh update --check -dosh --version -``` - -`dosh update` first tries a release tarball named for the platform -(`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest -Gitea/GitHub release. Gitea's concrete tag download route is detected when -`/releases/latest/download/...` is not supported. If that asset is not published -yet, it falls back to the source build path. If the release also publishes -`.sha256`, the installer verifies the archive before installing it. - -Install the client on Windows PowerShell: - -```powershell -$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex -``` - -Attach: - -```bash -dosh user@server.example.com -``` - -First-time setup for an existing SSH alias: - -```bash -dosh setup homelab -dosh selftest homelab -dosh homelab -``` - -`dosh setup ` imports the OpenSSH alias into -`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`, -and prints next commands. `dosh selftest ` checks local terminal overlay -safety, forwarding syntax, and the remote doctor path. - -Plain `dosh user@server.example.com` opens a fresh terminal session. Use named -sessions when you want to reattach to the same persistent terminal from multiple -clients: - -```bash -dosh --session work user@server.example.com -dosh --session logs user@server.example.com -``` - -Press `Ctrl-]` to detach the current client while leaving the server session alive. -Typing `exit` in the remote shell closes that Dosh session and returns the client. -If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings, -and attempts ticket-based reconnect. While the link is silent it shows a single, -non-destructive status line on the bottom screen row (`[dosh] last contact Ns ago -— reconnecting…`), drawn with save/restore cursor so it never moves the app cursor -or corrupts a full-screen TUI, and cleared the instant packets resume. (The old -in-band overlay wrote over the active line and could corrupt TUIs; this draws only -the last row.) Disable it with `disconnect_status = false` in `client.toml` or -`DOSH_DISCONNECT_STATUS=0`. - -If SSH and UDP use different public names, specify the UDP address: - -```bash -dosh-client --dosh-host public.example.com --dosh-port 50000 user@host -``` - -Forwarding can use SSH-shaped flags directly or the forward-only wrapper: - -```bash -dosh -N -L 8080:127.0.0.1:80 homelab -dosh forward homelab -L 8080:127.0.0.1:80 -D 1080 -``` - -Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts, -ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the -UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit -in Dosh's host config: - -```bash -dosh import-ssh homelab -dosh homelab -``` - -Optional command extensions are just config-side startup shortcuts; Dosh has no -compile-time dependency on the tools they run. For example, to make a separately -installed server-side `tm` dashboard easy to open: - -```toml -# ~/.config/dosh/client.toml -[extensions.tm] -command = "tm {args}" -description = "Open the server-side tmux dashboard" -``` - -Then `dosh homelab tm` sends `tm`, and `dosh homelab tm dosh` sends `tm 'dosh'`. -Remove that table to remove the integration. Hosts can override or opt out: - -```toml -# ~/.config/dosh/hosts.toml -[homelab.extensions.tm] -command = "/opt/tm/bin/tm {args}" - -[other-host.extensions.tm] -disabled = true -``` - -## Develop - -Build: - -```bash -cargo build -``` - -Attach locally, using local bootstrap instead of SSH: - -```bash -target/debug/dosh-client --local-auth --no-cache local -``` - -Benchmark local attach: - -```bash -target/debug/dosh-bench --local-auth --server local --iterations 5 -``` - -Benchmark a remote host over SSH bootstrap: - -```bash -target/release/dosh-bench --server user@host --ssh-port 22 --iterations 3 -``` - -Benchmark the ControlMaster-backed SSH bootstrap path: - -```bash -target/release/dosh-bench --server user@host --controlmaster --iterations 3 -``` - -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 -``` - -Run the same Docker comparison with Mosh installed in the benchmark container: - -```bash -make bench-docker-mosh -``` - -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 plus native encrypted forwarding on -Dosh-installed servers, not generic SSH compatibility. - -The latest local release evidence is in -`docs/RELEASE_EVIDENCE_2026-06-20.md`. - -Generate a publishable Markdown benchmark report: - -```bash -make bench-report -``` - -Set `DOSH_BENCH_SERVER`, `DOSH_BENCH_ITERS`, `DOSH_BENCH_ARGS`, and -`DOSH_BENCH_REPORT` to target a real host and choose the output path. - -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. - -Install release binaries and the user systemd service: - -```bash -make install -``` - -Build release tarballs for upload to Gitea/GitHub releases: - -```bash -make package-release -GITEA_TOKEN=... scripts/upload-gitea-release.sh v0.1.0 target/dosh-release/dosh-* -``` - -## Performance Rules - -The stack is performance-driven, not fixed by taste. Rust is the default because the -likely bottlenecks are network RTT, SSH startup/auth, PTY/shell creation, packet -size, and terminal rendering. Change language or runtime only if measurements show -they are the bottleneck. - -Hot-path rules: - -- Custom UDP protocol with AEAD for v0; no QUIC handshake on attach. -- Fixed binary packet headers for terminal traffic; no JSON on the protocol path. -- Preallocated buffers; avoid per-packet heap churn. -- Single-thread event loop is preferred for the hot path. -- No PTY allocation, shell spawn, shell rc files, or MOTD on attach to an existing - session. -- Initial snapshot should be sent in the first UDP reply when it fits under the - packet budget. - -## Goals - -- Connection speed as specified above. -- UDP roaming and reconnect. -- Encrypted terminal data. -- Reuse SSH pubkeys for first trust establishment. -- Named persistent sessions. -- Multiple clients attached to one session. -- Optional view-only clients. -- Single server port, not one port per session. -- Static server and client binaries where practical. - -## Non-Goals - -- Replacing SSH as the first public-key trust mechanism. -- Multi-user access control. -- Windows server/full parity in v0; the client installer supports prebuilt Windows - client artifacts when published. -- Full mosh compatibility. -- Perfect predictive local echo in the first MVP. +# Dosh + +Dosh is a fast encrypted remote terminal. It keeps the good parts of SSH and +Mosh for day-to-day shell work: quick attach, roaming over UDP, reconnect after +network changes, persistent server-side terminals, optional predictive typing, +and TCP forwarding. + +Dosh uses SSH-compatible bootstrap for first setup and can use its native +cached attach path after trust is established. The goal is simple: type +`dosh my-server` and land in a usable terminal about as fast as the network can +allow, then keep that terminal alive when Wi-Fi, sleep, or the client process +gets messy. ## Status -Rust implementation is present in this repository. It contains `dosh-server`, -`dosh-client`, `dosh-auth`, `dosh-bench`, shared auth/crypto/protocol modules, a -resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP attach -tickets, client ACKs, server retransmit bookkeeping, sliding replay protection, -server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install -script, Docker SSH benchmark gates, CI, and protocol/integration tests. +- Clients: macOS, Linux, and Windows. +- Server: Unix-like hosts with PTY support. +- Windows: client-only; install with PowerShell and connect to a Unix Dosh + server. +- Security: encrypted transport, pinned Dosh host keys, replay-resistant attach + tickets, and SSH fallback for bootstrap/recovery. +- Not included: X11 forwarding, SFTP/SCP replacement, or a Windows server. -### Native v1 +## Install -Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substantially -implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed -servers: +Unix/macOS server or client: -- **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 - SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so - bulk transfers do not lag the terminal. -- **Agent forwarding** (opt-in, security-gated): `-A` / `forward_agent` exports your - local `SSH_AUTH_SOCK` to a per-session proxy socket on the server (private dir, - mode 0600) and tunnels each connection back to your agent over a Dosh stream. Only - active on explicit client opt-in **and** server `allow_agent_forwarding = true`; it - applies to freshly spawned shells (not an already-running attached/prewarmed - session, the same constraint ssh/mosh have). -- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks. +```sh +curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- both --repo https://git.palav.dev/Palav/dosh.git --port 50000 +``` -Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`): -the native authenticated path is tried first and falls back to SSH bootstrap -explicitly when native auth is disabled, unavailable, or rejected. It never silently -degrades to an unauthenticated mode. +Unix/macOS client only: -Native v1 is **not externally audited yet**. Local 30-minute sleep/roaming soak, -fuzz-smoke, and Docker SSH/Mosh benchmark evidence is captured in -`docs/RELEASE_EVIDENCE_2026-06-20.md`. See `docs/THREAT_MODEL.md` for the -published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md` -for the v1 versioning policy, `docs/AUDIT_PACKET.md` for the external security -review handoff, and the "Native v1 verification checklist status" table in -`docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not claim generic -SSH compatibility; its defensible claim is fast encrypted native attach/reconnect -and forwarding on Dosh-installed servers with SSH bootstrap fallback. +```sh +curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- client --repo https://git.palav.dev/Palav/dosh.git +``` + +Windows client: + +```powershell +irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex +``` + +The installers prefer release binaries and fall back to a source build when +needed. + +## Use + +Set up a host from your SSH config: + +```sh +dosh setup my-server +``` + +Connect: + +```sh +dosh my-server +``` + +Run a command on connect: + +```sh +dosh my-server tm +``` + +Check the install and network path: + +```sh +dosh doctor my-server +dosh selftest my-server +``` + +Repair local cached attach state without changing host trust: + +```sh +dosh recover my-server +``` + +Update: + +```sh +dosh update +dosh update --check +``` + +## Forwarding + +Local forward: + +```sh +dosh forward my-server -L 8080:127.0.0.1:80 +``` + +Dynamic SOCKS forward: + +```sh +dosh forward my-server -D 1080 +``` + +Remote forward: + +```sh +dosh forward my-server -R 2222:127.0.0.1:22 +``` + +Agent forwarding is opt-in with `-A` and must also be allowed by the server. + +## Config + +Client config lives at: + +```text +~/.config/dosh/client.toml +~/.config/dosh/hosts.toml +``` + +Server config lives at: + +```text +~/.config/dosh/server.toml +``` + +Useful client defaults: + +```toml +default_session = "new" +auth_preference = "native,ssh" +predict = true +predict_mode = "experimental" +cache_attach_tickets = true +disconnect_status = true +``` + +## Development + +Build and test: + +```sh +cargo test +cargo build --release +``` + +Run the terminal/TUI regression harness: + +```sh +make tui-harness +``` + +Package release artifacts: + +```sh +sh scripts/package-release.sh +``` diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 7f39ec6..0000000 --- a/SPEC.md +++ /dev/null @@ -1,604 +0,0 @@ -# dosh - Dormant Shell Spec - -**Status:** Implemented Rust build with local and Docker SSH verification -**Default language:** Rust, unless benchmarks prove the stack is the bottleneck -**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench` -**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T` -**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` (substantially implemented; see its v1 status block) -**Threat model:** `docs/THREAT_MODEL.md` - ---- - -## 1. Product Shape - -dosh is a fast-attach remote terminal. It borrows the useful shape of mosh - UDP -transport, roaming, and latency-tolerant terminal rendering - but optimizes a -different first-order problem: getting the user back into an already-running terminal -as quickly as possible. - -The daemon is resident. Sessions are named. A session owns one PTY and one -authoritative terminal screen. Clients attach to that session over encrypted UDP. -SSH is used for first trust establishment and as fallback when cached credentials are -missing, expired, or rejected. Native Dosh auth is specified separately in -`docs/NATIVE_V1_SPEC.md` as the path toward replacing the day-to-day SSH workflow on -Dosh-installed servers. - ---- - -## 2. Design Goals - -- Connection speed first: - - UDP resume: one encrypted UDP request and one encrypted UDP reply. - - UDP attach ticket: one encrypted UDP request and one encrypted UDP reply. - - Warm SSH bootstrap: existing-ControlMaster SSH command latency plus one UDP RTT. - - Cold SSH bootstrap: cold `ssh host` terminal-ready time plus at most one UDP RTT. -- No daemon spawn, PTY spawn, shell startup, rc file execution, or MOTD on attach to - an existing session. -- Prewarm configured sessions at daemon startup, including `default` by default. -- Encrypted UDP terminal data. -- Single configurable UDP port. -- Multiple clients attached to one session. -- Optional read-only clients. -- Named persistent sessions. -- Reuse existing SSH key infrastructure. -- Instrument connection timing from the first implementation. - ---- - -## 3. Non-Goals - -- Replacing SSH as the first public-key trust mechanism. -- Multi-user authorization or ACLs. -- Windows support in v0. -- Full mosh protocol compatibility. -- Perfect local echo/prediction in the first MVP. -- QUIC in v0. QUIC can be revisited if measurements show custom UDP is not enough. - ---- - -## 4. Connection Speed Contract - -Measure terminal-ready time: elapsed time from launching `dosh ...` to first usable -terminal render. - -Benchmarks must use the same host, network, key, DNS path, and SSH config. - -| Path | Acceptance gate | -| --- | --- | -| UDP resume | <= one measured UDP RTT + local render time | -| UDP attach ticket | <= one measured UDP RTT + local render time | -| Warm SSH bootstrap | <= `ssh host true` over existing ControlMaster + one measured UDP RTT | -| Cold SSH bootstrap | <= cold `ssh host` terminal-ready time + one measured UDP RTT | -| New session | Report separately; PTY/shell creation is expected | - -Required timing evidence: - -- Client stderr spans for credential lookup, SSH bootstrap, UDP resume, UDP ticket - attach, and terminal-ready time. -- `dosh-bench` samples for SSH `true`, Dosh attach, and optional ControlMaster-backed - SSH `true`. -- `make bench-docker-ssh` gates both cold SSH bootstrap and ControlMaster-backed SSH - bootstrap against containerized OpenSSH plus resident `dosh-server`. - ---- - -## 5. Fast Path Order - -The client always tries the cheapest path that is valid for the requested -host/user/session/mode: - -1. **UDP resume** - - Requires cached `ClientId`, session key, server identity, and unexpired resume - metadata. - - Sends `ResumeRequest`. - - Receives `ResumeOk` with a snapshot or diff. - -2. **UDP attach ticket** - - Requires cached attach ticket scoped to server identity, SSH username, session, - mode, and expiry. - - Sends `TicketAttachRequest`. - - Receives `AttachOk` with session key, `ClientId`, and snapshot. - -3. **SSH bootstrap** - - Runs `ssh -T user@host ~/.local/bin/dosh-auth ...`. - - Receives attach token, attach ticket, session key material, and server metadata. - - Sends `BootstrapAttachRequest`. - - Receives `AttachOk` with `ClientId` and snapshot. - -4. **New session** - - Same as attach, but if the session does not exist and is not prewarmed, the server - creates PTY and shell before first paint. - ---- - -## 6. Architecture - -```text -dosh-server - config loader - secret manager - UDP socket on one port - session table: HashMap - optional prewarm of configured sessions - auth helper mode for SSH bootstrap - metrics/timing logger - -Session - PTY master - child process/shell - terminal parser - authoritative screen model - scrollback ring - monotonic output sequence - client table: HashMap - -ClientState - ClientId - UDP endpoint - mode: read-write | view-only - session key id - last acked sequence - terminal size - last seen timestamp - -dosh-client - config loader - local credential cache - terminal raw mode - UDP protocol engine - SSH bootstrap runner - reconnect state machine - renderer -``` - -Server hot-path ownership should avoid locks on every broadcast. A single event-loop -owner per session is preferred. Cross-thread designs are allowed only if benchmarked. - ---- - -## 7. Security Model - -SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies -on SSH for first authentication. Native v1 (`docs/NATIVE_V1_SPEC.md`) adds an opt-in -native public-key login over the Dosh UDP transport that is tried before SSH, with -its assets, attackers, and residual risks documented in `docs/THREAT_MODEL.md`. SSH -remains the explicit fallback and the host-key trust bootstrap path. - -The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable -speed, with `AES-GCM` allowed when hardware acceleration is known to be available. -The negotiated algorithm is recorded in the bootstrap response. - -All encrypted packets use: - -- Unique nonce per `(session_key_id, direction)`. -- Monotonic packet counter. -- Associated data containing protocol version, packet type, session name hash, - client id when known, and sequence numbers. -- Replay rejection using the packet counter window. - -Secrets: - -- `server_secret`: generated on first server start; stored mode `0600`. -- `session_key`: random 256-bit key per client attachment, rotated on SSH bootstrap - or ticket attach. -- `attach_ticket_key`: derived from `server_secret` and rotated by server key epoch. - -No terminal bytes are sent outside AEAD after the attach handshake begins. - ---- - -## 8. SSH Bootstrap Auth - -Client command: - -```bash -ssh -T user@host ~/.local/bin/dosh-auth \ - --protocol 1 \ - --nonce \ - --session \ - --mode \ - --size x \ - --client-version -``` - -`dosh-auth` must: - -- Not allocate a PTY. -- Not start a shell. -- Not run user shell rc files. -- Read server config and secret directly. -- Return one compact binary or base64url response on stdout. -- Exit immediately. - -Bootstrap response fields: - -- `protocol_version` -- `server_id` -- `server_key_epoch` -- `issued_at` -- `expires_at` -- `user` -- `session` -- `mode` -- `terminal_size` -- `attach_token` -- `attach_ticket` -- `attach_ticket_psk` -- `session_key` -- `session_key_id` -- `udp_host` -- `udp_port` -- `aead_algorithm` - -`attach_token = HMAC-SHA256(server_secret, user || session || mode || terminal_size || -client_nonce || issued_at || expires_at || session_key_id)`. - -The token TTL defaults to 30 seconds. Attach tickets default to 1 hour and are -server-configurable. - ---- - -## 9. Attach Tickets - -Attach tickets let a new client process attach without spawning SSH again. - -Ticket properties: - -- Server-sealed and authenticated by `attach_ticket_key`. -- Paired with a client-held random `attach_ticket_psk` returned during SSH bootstrap. -- Scoped to server identity, SSH username, session, mode, and key epoch. -- Short-lived by default. -- Revoked implicitly when server secret/key epoch changes. -- Stored client-side with mode `0600`, along with `attach_ticket_psk`. - -Ticket attach does not prove fresh possession of the SSH private key. It proves recent -possession of a server-issued credential. This is acceptable for speed, configurable, -and can be disabled with `allow_attach_tickets = false`. - -Ticket attach flow: - -1. Client sends `TicketAttachRequest` containing the sealed ticket, client nonce, and - requested terminal size. -2. The request body is AEAD-encrypted with a key derived from - `HKDF(attach_ticket_psk, client_nonce || "ticket-attach-request")`. -3. Server opens the sealed ticket, validates scope/expiry/key epoch, derives the same - request key, and decrypts the request. -4. Server creates a fresh session key and `ClientId`. -5. `AttachOk` is AEAD-encrypted with - `HKDF(attach_ticket_psk, client_nonce || server_nonce || "ticket-attach-ok")` and - carries the fresh session key metadata plus first snapshot. -6. Subsequent terminal packets use the fresh session key, not the ticket PSK. - ---- - -## 10. UDP Protocol - -UDP port defaults to `50000`. One socket handles all sessions and clients. - -Hot-path terminal packets use a fixed binary header: - -```text -magic 4 bytes "DOSH" -version 1 byte 1 -type 1 byte -flags 2 bytes -conn_id 16 bytes zero before client id is assigned -seq 8 bytes sender packet sequence -ack 8 bytes latest received peer sequence -body_len 2 bytes -body body_len bytes -tag AEAD tag, length depends on algorithm -``` - -Packet types: - -| Type | Direction | Encrypted | Purpose | -| --- | --- | --- | --- | -| `BootstrapAttachRequest` | client -> server | token-authenticated | Attach after SSH bootstrap | -| `TicketAttachRequest` | client -> server | ticket PSK | Attach with cached ticket | -| `AttachOk` | server -> client | yes | Assign client id and send first snapshot | -| `AttachReject` | server -> client | no terminal bytes | Reject and require SSH | -| `ResumeRequest` | client -> server | yes | Resume known client | -| `ResumeOk` | server -> client | yes | Endpoint updated; diff/snapshot follows | -| `Input` | client -> server | yes | PTY input bytes | -| `Resize` | client -> server | yes | Terminal size update | -| `Frame` | server -> client | yes | Screen diff or PTY byte frame | -| `Ack` | both | yes | Ack without payload | -| `Ping` / `Pong` | both | yes | Keepalive and RTT | -| `Detach` | client -> server | yes | Remove client, keep session | - -MTU target: - -- Default payload target: 1200 bytes. -- Larger datagrams may be enabled only after path MTU discovery. -- Snapshots larger than the target are chunked. - -Reliability: - -- Input packets are reliable and ordered per client. -- Output frames are sequenced; clients ack rendered sequence. -- Server retransmits unacked frames within a bounded window. -- If a client falls too far behind, server sends a fresh snapshot instead of replaying - unlimited diffs. - ---- - -## 11. Sessions and PTYs - -Named sessions: - -```bash -dosh host # open a fresh generated session -dosh --session work host # attach/create named persistent session -dosh --session work --view-only host -``` - -Session behavior: - -- One PTY per session. -- Sessions persist until killed or server exits. -- If a session has zero clients, the PTY keeps running. -- Plain client attaches use generated session names by default. -- `--session ` intentionally reuses or shares a persistent session. -- Configured sessions are prewarmed at daemon startup. -- If a requested session does not exist: - - `attach` creates it only when `create_on_attach = true`. - - `new` always creates it and fails if it already exists. - -Resize policy: - -- One PTY means one size. -- Read-write clients may resize. -- View-only clients never resize. -- Default policy: latest read-write resize wins. - ---- - -## 12. Screen State - -Server maintains the authoritative terminal model: - -- Visible grid. -- Cursor position and style. -- Alternate screen. -- Text attributes and colors. -- Scrollback ring. -- Monotonic output sequence. - -Initial attach: - -- Server sends a snapshot in the first UDP reply if it fits the packet budget. -- If not, server sends a minimal first frame immediately and follows with chunks. - -Diffs: - -- Diffs are computed per client from that client's last acked rendered sequence. -- Lagging clients may receive larger diffs or a full snapshot. -- Diffs are preferred over raw PTY bytes for reconnect correctness. - -Encoding: - -- Hot terminal frames use fixed binary headers and compact binary payloads. -- MessagePack is allowed only for non-hot control/list/config responses. -- JSON is not used on the protocol path. - ---- - -## 13. Multi-Client Model - -Default mode is shared input. Any read-write client can write to the session PTY. -All clients see the same resulting screen. - -View-only mode: - -- Client suppresses local input. -- Server rejects `Input` from view-only clients even if a malformed client sends it. -- Promotion/demotion requires reconnect. - -Client timeout: - -- Clients are removed after `client_timeout_secs` without ack/ping. -- The default is intentionally long enough for laptop sleep/resume, currently one - day. Short timeouts make Dosh fall back to attach tickets or SSH after sleep. -- Removing a client never kills the session. - ---- - -## 14. Local Echo - -MVP local echo is conservative: - -- Printable keystrokes may be rendered optimistically only when the client is in a - simple shell line-editing state. -- Server output is always authoritative. -- On mismatch, client replaces local prediction with server state. - -Full mosh-style predictive display is a later feature. It must not delay the first -implementation of fast attach/resume. - ---- - -## 15. Reconnect and Roaming - -Client detects possible disconnect when no server packet arrives for -`reconnect_timeout_secs`. - -Reconnect order: - -1. Send encrypted `ResumeRequest` to the configured host/port. -2. If accepted, update endpoint server-side and receive diff/snapshot. -3. If rejected, try attach ticket. -4. If ticket attach is rejected, run SSH bootstrap. - -The server matches resume by `ClientId` and session key id, not by source address. -Successful resume updates the client's UDP endpoint. - ---- - -## 16. Configuration - -Server config: `~/.config/dosh/server.toml` - -```toml -port = 50000 -bind = "0.0.0.0" -scrollback = 5000 -auth_ttl_secs = 30 -attach_ticket_ttl_secs = 3600 -allow_attach_tickets = true -client_timeout_secs = 86400 -retransmit_window = 256 -default_input_mode = "read-write" -prewarm_sessions = ["default"] -create_on_attach = true -shell = "/usr/bin/zsh" -sessions_dir = "~/.local/share/dosh/sessions" -secret_path = "~/.config/dosh/secret" -``` - -Client config: `~/.config/dosh/client.toml` - -```toml -server = "user@example.com" -update_repo = "https://git.palav.dev/Palav/dosh.git" -update_port = 50000 -ssh_auth_command = "~/.local/bin/dosh-auth" -# ssh_port = 22 -dosh_port = 50000 -default_session = "new" -reconnect_timeout_secs = 5 -view_only = false -predict = true -predict_mode = "experimental" -cache_attach_tickets = true -credential_cache = "~/.local/share/dosh/credentials" -``` - ---- - -## 17. Performance-First Stack - -Default implementation: - -```text -# server/client shared -bytes -chacha20poly1305 -aes-gcm optional -hmac -hkdf -sha2 -rand -serde -toml - -# server -mio or tokio # benchmark; single-thread hot path either way -rustix # PTY/process/syscall wrappers where possible -vt100 # authoritative terminal parser/model - -# client -mio or tokio -crossterm # raw terminal mode -vt100 optional # only if client-side model is needed for prediction -``` - -Rules: - -- Benchmark `mio` vs single-thread `tokio` before committing to runtime. -- Avoid locks on per-packet session broadcast. -- Preallocate packet buffers. -- Avoid serde on terminal frames. -- Keep `dosh-auth` tiny and static where practical. -- Optimize startup path before throughput. - ---- - -## 18. MVP Scope - -MVP must include: - -- `dosh-server` daemon. -- `dosh-auth` SSH helper mode. -- `dosh-client`. -- One UDP port. -- Prewarmed `default` session. -- SSH bootstrap attach. -- Attach-ticket UDP attach. -- Encrypted UDP channel. -- UDP resume. -- Raw terminal input/output. -- Basic resize. -- Timing instrumentation. - -MVP may defer: - -- Sophisticated predictive local echo. -- Per-cell minimal diffs; raw frame plus snapshot fallback is acceptable initially if - reconnect correctness is preserved. -- Multi-session management commands beyond `attach`, `new`, `list`, and `kill`. - ---- - -## 19. Verification Checklist - -A build is not done until these are demonstrated: - -- Cold attach timing compared against cold `ssh host`. -- Warm attach timing compared against `ssh host true` with ControlMaster. -- UDP resume completes without spawning SSH. -- Existing session attach does not spawn PTY or shell. -- Prewarmed `default` exists before first client. -- Terminal data is encrypted on UDP. -- Replay counters reject duplicate encrypted packets. -- View-only clients cannot write to PTY. -- Multiple clients see the same screen. -- Client survives source port/IP change by resume. -- Snapshot fallback repairs a lagging client. -- `README.md` and `SPEC.md` remain consistent with implemented behavior. - ---- - -## 20. Status - -Spec complete. The Rust implementation is present in this repository. - -Implemented: - -- Rust workspace and binaries: `dosh-server`, `dosh-client`, `dosh-auth`. -- Server config and secret creation. -- SSH/local bootstrap response generation. -- HMAC bootstrap verification. -- ChaCha20-Poly1305 encrypted UDP packets. -- Fixed DOSH packet header. -- Resident server with prewarmed named PTY sessions. -- Authoritative server-side `vt100` terminal parser. -- Full attach/resume snapshots from terminal screen state. -- Per-client screen-state diffs for broadcast frames. -- Raw terminal client attach. -- UDP resume from cached client credentials. -- Sealed attach-ticket UDP attach after server restart or unknown-client resume. -- Client ACKs and server-side bounded pending retransmit window. -- Sliding replay window for encrypted client packet counters. -- View-only server-side input rejection. -- Basic resize handling. -- Timing output for bootstrap and terminal-ready. -- `dosh-bench` benchmark harness for attach timing, SSH key/known-host options, and - ControlMaster-backed SSH measurement. -- Hardened user systemd unit. -- Release install script. -- Docker OpenSSH benchmark gate covering cold SSH bootstrap and ControlMaster-backed - SSH bootstrap. -- GitHub Actions CI for format, tests, release build, and Docker SSH benchmark gate. -- Optional GitHub Actions remote benchmark job gated by repository secrets. -- Auth/protocol tests. -- Integration smoke tests for local attach, ticket attach after server restart, and - view-only input rejection. -- Integration tests for retransmit, resize, multi-client shared screen, and UDP - endpoint roaming. - -Optional deployment evidence: - -- Configure `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository - secrets to run the same benchmark against a real remote host in addition to the - Docker OpenSSH gate. diff --git a/docs/AUDIT_PACKET.md b/docs/AUDIT_PACKET.md deleted file mode 100644 index ce1a8af..0000000 --- a/docs/AUDIT_PACKET.md +++ /dev/null @@ -1,75 +0,0 @@ -# Dosh Native v1 Audit Packet - -This is the handoff checklist for an external security review. - -## Scope - -Review the security properties of Dosh native v1: - -- Native UDP authentication and key exchange -- Host key trust and known-host mismatch behavior -- Authorized key parsing and policy enforcement -- Transport encryption, replay protection, rekeying, resume, and stale packet handling -- Attach ticket sealing/opening -- TCP forwarding and agent forwarding authorization boundaries -- Parser robustness for untrusted wire/config data - -Primary files: - -- `src/native.rs` -- `src/protocol.rs` -- `src/auth.rs` -- `src/ssh_agent.rs` -- `src/bin/dosh-client.rs` -- `src/bin/dosh-server.rs` -- `src/config.rs` -- `tests/parser_robustness.rs` -- `tests/protocol_auth.rs` -- `tests/hostile_network.rs` -- `tests/integration_smoke.rs` -- `fuzz/fuzz_targets/*` - -Primary docs: - -- `docs/NATIVE_V1_SPEC.md` -- `docs/THREAT_MODEL.md` -- `docs/PROTOCOL_VERSIONING.md` -- `docs/PUBLIC_READINESS.md` -- `docs/RELEASE_EVIDENCE_2026-06-20.md` - -## Required Reviewer Questions - -1. Is the native handshake transcript complete enough to bind client/server keys, - requested session, mode, env, and forwarding permissions? -2. Are host-key trust transitions fail-closed, especially unknown vs mismatch vs - explicit TOFU? -3. Are authorized-key options parsed conservatively enough, and are unsupported - options rejected safely? -4. Can replayed, reordered, delayed, stale, or cross-epoch packets affect terminal - input or forwarded streams more than once? -5. Does key rotation preserve confidentiality without creating split-brain epoch - states? -6. Do attach tickets create any bearer-token replay or privilege-extension issue? -7. Can UDP endpoint migration be abused for hijack or reflection? -8. Are local/remote/dynamic TCP forwarding and agent forwarding gated correctly by - client opt-in, server config, and authorized-key options? -9. Are parser and packet-size limits sufficient against malicious inputs? -10. Are there any unsafe assumptions in process/session persistence or holder - adoption? - -## Current Local Evidence - -As of 2026-06-20: - -- `cargo test`: `153 passed, 1 ignored` -- `make soak-local`: passed 30-minute sleep/roaming soak -- `make fuzz-smoke`: passed all configured fuzz targets for 20s each -- `make bench-docker-mosh`: passed SSH, ControlMaster, native, cached, and Mosh - comparison gates - -## Explicit Non-Claims Before Audit - -- No claim of generic SSH protocol replacement. -- No claim of full Mosh compatibility. -- No claim that native v1 is externally audited. -- No claim that Windows server support is production-ready. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md deleted file mode 100644 index 2fa4c16..0000000 --- a/docs/BENCHMARKS.md +++ /dev/null @@ -1,203 +0,0 @@ -# Dosh Benchmarks - -This document explains how to run the Dosh benchmarks, what each metric means, -and how to read the results honestly. Dosh's headline claim is *insane speed on -repeat attach/reconnect* — these benchmarks exist to make that claim measurable -and reproducible. - -> **Read first:** `docs/PUBLIC_READINESS.md`. The defensible public claim is fast -> attach/reconnect, not full Mosh feature parity, and **not** cold startup. Cite -> `dosh_cached_attach_ms` for the core speed claim; cite cold metrics only to show -> the fallback path stays competitive with ordinary SSH. - -## TL;DR - -```bash -make bench-local # safe, self-contained matrix on a throwaway server -make bench-local-json # same, machine-readable JSON with raw samples -make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI -make bench-docker-mosh # same, with Mosh installed for a three-way comparison -make bench-report # Markdown report wrapper around dosh-bench -``` - -`make bench-local` never touches a running production server: it builds release -binaries, starts its own `dosh-server` bound to `127.0.0.1` on a random free UDP -port inside a temporary `HOME`, runs the matrix, prints raw samples plus summary -statistics, and tears everything down on exit. It will refuse to bind UDP port -`50000` (the default production port). - -## The benchmark binary: `dosh-bench` - -`dosh-bench` spawns the real `dosh-client` once per iteration and times the whole -process from launch to terminal-ready-and-detached. That wall-clock cost is the -apples-to-apples comparison against `ssh host true`: it is the startup price each -tool pays before any useful remote work begins. - -Every run prints, per metric, a summary line (count, min, median, p95, mean, max -in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable -object per run (with the full `samples_ms` array) so published numbers can always -include raw data, as required by `docs/PUBLIC_READINESS.md`. Pass `--markdown` for a -publishable Markdown table, and `--output path/to/report.md` to write it to disk. - -### Path matrix - -`dosh-bench` can benchmark these Dosh attach paths. Without an explicit path flag -it keeps its legacy single-path behavior (driven by `--local-auth` / `--warm-cache` -/ `--no-cache`) so existing CI scripts keep working. - -| Flag | Metric | What it measures | -| --- | --- | --- | -| `--cold-native` | `dosh_cold_native_ms` | Native cold auth: full `--auth native --no-cache` handshake to first frame and detach. The cold fallback when no cache exists. | -| `--cached-ticket` | `dosh_cached_attach_ms` | Cached attach-ticket fast path. Warms the cache once, then measures repeat attaches using cached UDP credentials/tickets. **This is the core speed claim.** | -| `--resume` | `dosh_resume_ms` | UDP resume of a session whose endpoint changes. See the caveat below — only meaningful when a live session is kept open out-of-band. | -| `--local-auth` | `dosh_local_attach_ms` | Self-contained local bootstrap; no SSH, no native handshake. Useful as a lower-bound sanity check on loopback. | -| *(default, no flag)* | `dosh_attach_ms` | Cold SSH bootstrap plus UDP attach (legacy single-path mode). | - -Existing baseline/comparison metrics are unchanged: - -| Metric | Meaning | -| --- | --- | -| `ssh_true_ms` | `ssh host true` with the same key/options. | -| `mosh_start_true_ms` | `mosh host -- true` bootstrap, run `true`, exit (timed inside a PTY). | - -### Assertions / gates - -| Flag | Gate | -| --- | --- | -| `--assert-ssh-plus-ms N` | The primary Dosh metric must be ≤ `ssh_true_ms` mean + `N` ms. | -| `--assert-mosh-minus-ms N` | The primary Dosh metric must be at least `N` ms faster than `mosh_start_true_ms`. | -| `--assert-dosh-max-ms N` | The primary Dosh metric mean must be ≤ `N` ms. | - -The "primary Dosh metric" for assertions is, in priority order: -`dosh_cached_attach_ms` → `dosh_resume_ms` → `dosh_cold_native_ms` → -`dosh_attach_ms` → `dosh_local_attach_ms`. - -## What each benchmark does - -### `make bench-local` (`scripts/bench-local.sh`) - -Self-contained, no SSH, no Docker. It: - -1. Builds release binaries (`DOSH_BENCH_PROFILE=debug` for the debug profile). -2. Picks a free UDP port (never `50000`) and a temp `HOME`. -3. Generates a throwaway client identity (`ssh-keygen`), authorizes it on the - server, and writes a throwaway server + client config (native auth, - trust-on-first-use, localhost UDP). -4. Starts `dosh-server serve` bound to `127.0.0.1` on that port. -5. Runs `dosh-bench --cold-native --cached-ticket`, then `--local-auth --no-cache`. -6. Sanity-checks that native cold auth actually trusted the host (so a silent - config-parse fallback can't make the benchmark measure the wrong path). -7. Kills the server and removes the temp `HOME` on exit. - -Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1`, -`DOSH_BENCH_PROFILE=release|debug`. - -### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`) - -Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the -`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster 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 - -- **Same machine, same network path.** `bench-local` runs everything on loopback, - so it isolates Dosh's own process/handshake/render overhead and removes network - RTT from the comparison. Real-world cached attach is approximately *network RTT - plus the local overhead these numbers measure*. Publish the machine, OS, CPU, - network path, and sample count alongside any numbers. -- **Wall-clock of the whole client process.** Each sample includes process spawn, - attach, first-frame render, and detach. That is deliberately the same thing - `ssh host true` pays, but it means a few ms is fixed process-startup cost, not - protocol cost. -- **Do not cite cold metrics as the headline.** `dosh_cold_native_ms` and - `dosh_attach_ms` still pay first-authentication cost. They exist to show the - fallback path stays competitive with ordinary SSH. Cite `dosh_cached_attach_ms` - for repeat attach/reconnect, per `docs/PUBLIC_READINESS.md`. -- **UDP resume is the roaming path, not cold reconnect.** Resume reconnects a - client that is *still attached* when its network endpoint changes (sleep/Wi-Fi - switch). The `--attach-only` benchmark model detaches after every iteration, - which removes the client on the server, so a fresh-process resume has nothing - live to resume and is not meaningful in `bench-local`. The cold fresh-process - reconnect fast path is the attach ticket (`dosh_cached_attach_ms`). Roaming - resume correctness is covered by the integration test - `resume_updates_udp_endpoint_for_roaming` in `tests/integration_smoke.rs`. - `dosh-bench --resume` remains available for scenarios that keep a live session - open out-of-band (for example remote soak tests). -- **These are not identical workloads.** SSH/Mosh/Dosh do different work at - startup. The comparison is still useful because it measures the startup tax each - tool charges before useful remote work begins. - -## Sample results (loopback, self-contained) - -Captured with `make bench-local` (`scripts/bench-local.sh 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 -- Toolchain: rustc 1.96.0, release profile -- Network path: loopback (`127.0.0.1`), throwaway server on a free UDP port -- Workload: `dosh-client --attach-only` to first frame and detach - -| Metric | n | min | median | p95 | mean | max | -| --- | --- | --- | --- | --- | --- | --- | -| `dosh_cold_native_ms` | 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.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: -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.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 -handshake; cached attach is ~3 ms because it reuses cached credentials and skips -the handshake entirely. On loopback there is no network RTT, so this is the local -overhead floor. Over a real link, expect cached attach ≈ this floor + one network -round trip — which is exactly the "near network RTT" target in the spec. There is -no SSH/Mosh baseline in this loopback table because those paths need an SSH server; -use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head. - -## 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 deleted file mode 100644 index a4c261f..0000000 --- a/docs/NATIVE_V1_SPEC.md +++ /dev/null @@ -1,666 +0,0 @@ -# Dosh Native v1 Spec - -> **v1 status (annotation, not part of the spec text below).** Native v1 is -> substantially implemented and being stabilized; it is not yet fully verified. -> Milestone progress against section 15: -> -> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`, -> `known_hosts`, and mismatch hard-fail are implemented. -> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/ -> `AuthOk`, ssh-agent and 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. 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`, per-stream flow control, ordered stream offsets/ACKs, and stream-data -> retransmission exist. Terminal-priority/load, replay/reorder, and dropped -> server-to-client `StreamData` recovery regressions are covered. -> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting, -> 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/loss tests, and independent persistent session -> restart tests exist. Published long-soak evidence is not yet complete. The threat -> model is published (`docs/THREAT_MODEL.md`). -> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust -> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is -> ongoing. -> -> The section 16 verification checklist is **not yet fully green** — see the -> item-by-item status table in `docs/PUBLIC_READINESS.md` and the residual-risk list in -> `docs/THREAT_MODEL.md`. The section 17 public-claim gate is therefore **not met**. - -Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to -replace the user's day-to-day `ssh host` workflow for terminals and forwarding while -keeping SSH as a compatibility and recovery fallback. - -Native Dosh is not an RFC-compatible SSH implementation. It deliberately avoids the -full SSH transport/channel protocol and implements the smaller set of behavior Dosh -needs: authenticated login, encrypted terminal transport, reconnect/roaming, and TCP -forwarding. - -## 1. Product Contract - -User-facing commands: - -```bash -dosh host -dosh host command... -dosh -L 8080:127.0.0.1:80 host -dosh -R 9000:127.0.0.1:9000 host -dosh --session work host -dosh --view-only --session work host -dosh host tm -``` - -Compatibility expectations: - -- Existing SSH keys and `ssh-agent` are reused. -- Server-side authorization uses `~/.ssh/authorized_keys`. -- Host trust is pinned in a Dosh known-hosts file and can be bootstrapped by SSH. -- `~/.ssh/config` host aliases, `HostName`, `User`, `Port`, `IdentityFile`, - `ProxyJump`, and `UserKnownHostsFile` are honored where practical. -- SSH bootstrap remains available with `--auth=ssh` and is used automatically when - native auth is disabled or cannot complete. - -Native v1 is complete only if a normal interactive user can switch their daily -workflow from `ssh` to `dosh` without keeping SSH open in another tab for routine -tasks. - -Non-goals for v1: - -- RFC-compatible SSH server/client behavior. -- Arbitrary SSH subsystems. -- X11 forwarding. -- SFTP/SCP compatibility. -- Multi-user daemon mode with privileged account switching. -- Replacing OpenSSH on hosts that do not run `dosh-server`. - -## 2. SSH Workflow Parity Target - -Native v1 targets the SSH workflows interactive users actually use, not the entire -OpenSSH feature universe. - -Must work in v1: - -- Interactive shell: `dosh host`. -- Remote command: `dosh host command...`. -- Fresh terminal by default. -- Named persistent terminal: `dosh --session work host`. -- Shared/view-only session: `dosh --view-only --session work host`. -- Local forwarding: `dosh -L [bind:]listen:target:target_port host`. -- Remote forwarding: `dosh -R [bind:]listen:target:target_port host`. -- Dynamic forwarding: `dosh -D [bind:]listen host`, if stream flow control is proven; - otherwise it is the first v1.1 item and must be called out honestly. -- Multiple forwards in one connection. -- Forward-only mode: `dosh -N -L ... host`. -- Background forwarding: `dosh -f -N -L ... host`, or an equivalent supervised - user-service mode. -- SSH-agent authentication. -- Encrypted OpenSSH private-key authentication with prompt. -- `~/.ssh/config` host aliases for `Host`, `HostName`, `User`, `Port`, - `IdentityFile`, `ProxyJump`, `UserKnownHostsFile`, and `IdentitiesOnly`. -- Dosh host config overrides for user, Dosh-specific UDP host/port/auth policy. -- Clear host-key trust and mismatch errors. -- Clear auth failure errors that name the attempted key source. -- Stable reconnect after sleep, network switch, NAT rebinding, and server packet loss. -- `dosh update`. -- `dosh doctor host` for config/auth/UDP reachability diagnostics. -- `dosh sessions host` for session visibility. -- Optional command extensions such as `dosh host tm` for companion tools that are - installed separately from Dosh. - -Should work in v1 if it does not compromise the transport schedule: - -- Agent forwarding with an explicit opt-in flag and clear warning. -- `ProxyJump` through SSH for bootstrap/trust and through Dosh-native relay later. -- `SendEnv`/`SetEnv` equivalent for explicit environment variables. -- Clipboard integration as a separate opt-in channel. - -Explicitly not v1: - -- X11 forwarding. -- SFTP/SCP wire compatibility. -- Full OpenSSH config language. -- Full `sshd` replacement for arbitrary SSH clients. -- Forced-command subsystems beyond fail-closed enforcement. - -## 3. Stability Contract - -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 - (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 - `persist_sessions = false` the old behavior applies: live PTYs drop on restart, - but the client must still fail clearly and reconnect cleanly afterward. -- Decrypt failures from stale packets must be ignored or trigger reconnect, never - terminate the terminal by themselves. -- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate - screen, and raw mode on exit. -- Forwarding streams must close cleanly without leaving orphan listeners. -- Every public error must be actionable: host trust, auth failure, UDP blocked, - forwarding denied, version mismatch, or server unavailable. -- `dosh host` must never attach to another active unnamed terminal by accident. -- The default install must be secure without hand-editing configs. -- Upgrades must preserve existing trusted host keys and credentials unless explicitly - rotated. - -## 4. Security Contract - -Native Dosh must match the security properties users rely on from SSH for this use -case: - -- Server authentication before terminal data is trusted. -- User authentication by possession of an authorized private key or agent key. -- Forward secrecy for terminal and forwarding traffic. -- AEAD encryption and authentication for every post-handshake packet. -- Replay protection for handshake and transport packets. -- Host-key pinning with explicit first-use behavior. -- No plaintext terminal bytes after handshake begins. -- No custom cryptographic primitives. -- Clear downgrade behavior: native auth failure must not silently fall back to an - unauthenticated mode. - -Native Dosh does not claim SSH's full protocol security surface. It claims equivalent -security for Dosh terminal and forwarding sessions on Dosh-installed servers. - -## 5. Threat Model - -In scope: - -- Passive network observer. -- Active network attacker that can spoof, drop, replay, reorder, or modify packets. -- NAT rebinding and client IP/port changes. -- Stolen attach-ticket cache without the user's private key. -- Server restart and key rotation. -- Malicious unauthenticated client flooding auth attempts. -- Compromised low-privilege local user trying to read Dosh caches on a shared client. - -Out of scope: - -- Compromised client machine. -- Compromised server account. -- Malicious kernel, terminal emulator, or PTY implementation. -- Protecting against a server that is already authorized and then becomes malicious. - -## 6. Cryptographic Building Blocks - -Allowed primitives: - -- Handshake pattern: Noise `NK` or `XX` through a maintained Rust Noise framework, or - a small audited construction over `x25519-dalek` plus transcript binding. -- KEX: X25519. -- Signatures for user auth: Ed25519 and ECDSA P-256 via SSH-agent and OpenSSH key - formats. RSA may be accepted only for compatibility and must use SHA-2 signatures. -- AEAD: ChaCha20-Poly1305 by default; AES-GCM optional when hardware support is known. -- KDF: HKDF-SHA256. -- Hash/transcript: SHA-256. -- Randomness: OS CSPRNG only. - -Disallowed: - -- Homegrown ciphers, MACs, padding, or key derivation. -- Reusing a nonce/key pair. -- Unauthenticated encryption. -- MD5/SHA-1 signatures for user auth. - -## 7. Identity And Trust - -### Server Identity - -Each `dosh-server` has a persistent host key: - -```text -~/.config/dosh/host_key -~/.config/dosh/host_key.pub -``` - -Default host-key algorithm: Ed25519. - -Client pins host keys in: - -```text -~/.config/dosh/known_hosts -``` - -Entry format: - -```text -host-pattern key-type base64-public-key first-seen=unix-seconds source=tofu|ssh|manual -``` - -First-use policy: - -- Default for public internet: refuse unknown native host key and suggest - `dosh trust host` or SSH bootstrap. -- Default for local/private hosts may be TOFU only when `trust_on_first_use = true`. -- `dosh trust host` may verify the Dosh host key over the existing SSH bootstrap path. - -Host-key mismatch: - -- Hard fail. -- Print old fingerprint, new fingerprint, and known-hosts file path. -- Never auto-replace. - -### User Identity - -Native auth user identity is the login user resolved from: - -1. Explicit CLI user: `user@host`. -2. Dosh host config. -3. `ssh -G host` `user`. -4. Local username. - -Server verifies user keys against: - -```text -~/.ssh/authorized_keys -~/.config/dosh/authorized_keys -``` - -`~/.config/dosh/authorized_keys` is optional and may restrict Dosh access without -changing SSH access. - -Authorized-key options required in v1: - -- `from=` -- `command=` must reject native Dosh terminal login unless explicitly supported later. -- `restrict` -- `no-port-forwarding` -- `permitopen=` - -Unsupported restrictive options must fail closed. - -## 8. Native Auth Handshake - -Native auth runs over UDP on the same Dosh port. It establishes a short-lived -authenticated control channel and returns the same terminal attach material that SSH -bootstrap returns today. - -Target path: - -```text -client -> server: ClientHello -server -> client: ServerHello -client -> server: UserAuth -server -> client: AuthOk + first terminal snapshot -``` - -The server may combine `AuthOk` and `AttachOk` to get terminal-ready in the final -handshake flight. - -### ClientHello - -Fields: - -- protocol version -- client random -- client ephemeral X25519 public key -- requested host alias -- requested user -- requested session -- requested mode -- terminal size -- supported AEAD algorithms -- supported user key algorithms -- optional cached host-key id -- optional attach ticket envelope - -### ServerHello - -Fields: - -- protocol version -- server random -- server ephemeral X25519 public key -- server host public key -- server host-key signature over transcript -- chosen AEAD -- server key epoch -- auth challenge -- rate-limit metadata when applicable - -The client must verify the host key before sending user authentication. - -### UserAuth - -Fields: - -- selected public key -- key algorithm -- signature over transcript and auth challenge -- optional agent identity metadata -- optional requested forwarding declarations - -The signature must bind: - -- both ephemeral keys -- both randoms -- server host key -- requested user/session/mode/terminal size -- selected algorithms -- protocol version - -### AuthOk - -Fields: - -- assigned `ClientId` -- session name -- mode -- session key id -- encrypted session key material or derived key confirmation -- attach ticket -- attach ticket PSK encrypted to the handshake key -- initial output sequence -- first snapshot -- server policy flags - -`AuthOk` is AEAD-encrypted under the handshake traffic key. - -## 9. Key Schedule - -Handshake transcript: - -```text -H = SHA256(protocol_label || ClientHello || ServerHello || UserAuth) -``` - -Shared secret: - -```text -dh = X25519(client_ephemeral, server_ephemeral) -``` - -Handshake key: - -```text -handshake_key = HKDF-SHA256(dh, H, "dosh/native/handshake/v1") -``` - -Session traffic keys: - -```text -c2s_key = HKDF-SHA256(handshake_key, H, "dosh/native/c2s/v1") -s2c_key = HKDF-SHA256(handshake_key, H, "dosh/native/s2c/v1") -``` - -Attach ticket PSKs and rotated session keys must be derived independently from server -secret material and fresh randomness. They must not reuse handshake traffic keys. - -## 10. Attach Tickets And Cache - -Native attach tickets replace most cold auth after first login. - -Ticket properties: - -- Server-sealed AEAD blob. -- Scoped to server host key, user, session, mode, client key fingerprint, server key - epoch, and policy flags. -- Paired with a client-held random PSK. -- Default TTL: 24 hours for trusted personal machines, configurable down to zero. -- Stored mode `0600`. -- Revoked by server host-key rotation, server secret rotation, or user key removal. - -Client cache path: - -```text -~/.local/share/dosh/credentials/ -``` - -Cache entries must include: - -- host identity fingerprint -- user -- session -- mode -- ticket expiry -- last rendered sequence -- client id -- session key id -- attach ticket -- attach ticket PSK - -## 11. Transport - -Post-auth terminal traffic continues to use the current Dosh UDP packet model: - -- fixed binary header -- AEAD body -- monotonic packet sequence -- ack field -- replay window -- server snapshots for recovery - -Required v1 changes: - -- Separate packet namespaces for terminal frames, control messages, and forwarding - streams. -- Explicit `key_epoch` or `session_key_id` in packet metadata so stale packets can be - ignored without fatal decrypt errors. -- Rekey command after configurable packet count or wall-clock interval. -- Connection migration must be accepted after any valid encrypted packet from a new - source address. - -## 12. Forwarding - -Native forwarding is a Dosh stream multiplexer over the encrypted transport. - -CLI: - -```bash -dosh -L [bind_host:]listen_port:target_host:target_port host -dosh -R [bind_host:]listen_port:target_host:target_port host -dosh -D [bind_host:]listen_port host -dosh -A host # forward the local ssh-agent (opt-in, server-gated) -``` - -Stream packet types: - -- `StreamOpen` -- `StreamOpenOk` -- `StreamOpenReject` -- `StreamData` -- `StreamWindowAdjust` -- `StreamEof` -- `StreamClose` - -Forwarding rules: - -- Terminal traffic has priority over stream bulk data. -- Each stream has independent flow control. -- `StreamData` carries a per-stream byte offset and is delivered to TCP in order. -- `StreamWindowAdjust` carries a cumulative received byte offset; peers free - retransmit buffers and replenish send credit only for acknowledged contiguous - bytes. -- Unacknowledged stream bytes are retransmitted as newly encrypted transport packets - with fresh packet sequence numbers/nonces. -- Backpressure must not block PTY input or output. -- Server enforces `no-port-forwarding` and `permitopen=`. -- Remote listeners bind to loopback by default. -- Non-loopback remote bind requires explicit config. -- `-N` opens forwarding without creating a PTY. -- `-f` backgrounds only after all requested listeners are successfully active. -- Listener setup failures fail the entire command unless `--partial-forwarding` is - explicitly requested. -- Forwarding reconnect must preserve listeners and re-open streams after network - migration when protocol state allows it. -- Stream packet scheduling must enforce terminal priority; a large port-forward copy - cannot make shell keystrokes lag. - -### 12.1 SSH-Agent Forwarding (opt-in, security-gated) - -- Activated only on explicit client opt-in (`-A` / `forward_agent = true`) **and** - server `allow_agent_forwarding = true`. Off by default on both ends. -- The client requests a `ForwardingKind::Agent` entry during auth. If the server - policy allows it, the server binds a per-session proxy unix socket in a - process-private directory (dir 0700, socket 0600) and exports its path as the - spawned shell's `SSH_AUTH_SOCK`. -- Each connection from a remote process to that proxy socket is tunneled to the - client over a server-initiated `StreamOpen` carrying the reserved target sentinel - `@dosh-agent` (port 0). The client splices it into its local `SSH_AUTH_SOCK` - (a unix socket, not a TCP target). Data/window/close reuse the existing stream - packets unchanged. -- Only applies to a freshly spawned shell; an already-running attached/prewarmed - session keeps its existing environment (same constraint as ssh/mosh). -- SECURITY: forwarding exposes the local agent to the remote host for the session's - lifetime, which is why it is double-gated and never the default. - -## 13. Diagnostics And Operations - -Native v1 must include operations commands: - -```bash -dosh doctor host -dosh trust host -dosh trust --remove host -dosh sessions host -dosh update -``` - -`dosh doctor host` checks: - -- host alias resolution -- Dosh known-host state -- SSH fallback reachability -- native UDP reachability -- server version -- native auth enabled/disabled -- usable keys from ssh-agent and identity files -- server authorization result without opening a terminal -- configured forwarding policy - -`dosh trust host` checks: - -- fetch Dosh host key through SSH fallback when available -- show fingerprint before writing trust -- refuse overwrite on mismatch unless `--replace` is explicit - -## 14. Config - -Client: - -```toml -auth_preference = "native,ssh" -trust_on_first_use = false -native_auth_timeout_ms = 700 -known_hosts = "~/.config/dosh/known_hosts" -credential_cache = "~/.local/share/dosh/credentials" -identity_files = ["~/.ssh/id_ed25519"] -use_ssh_agent = true -forward_agent = false -send_env = ["LANG", "LC_*", "TERM", "COLORTERM"] -set_env = {} -forwardings = [] - -[extensions.tm] -command = "tm {args}" -description = "Open an optional server-side tmux dashboard" -``` - -Server: - -```toml -native_auth = true -host_key = "~/.config/dosh/host_key" -authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"] -native_auth_rate_limit_per_minute = 30 -attach_ticket_ttl_secs = 86400 -allow_tcp_forwarding = true -allow_remote_forwarding = false -allow_remote_non_loopback_bind = false -allow_agent_forwarding = false -accept_env = ["LANG", "LC_*", "TERM", "COLORTERM"] -``` - -## 15. Migration Plan - -Milestone 1: host identity and trust - -- Generate Dosh host key on server install. -- Add `dosh trust host`. -- Add known-hosts file and mismatch handling. -- Keep SSH bootstrap as the only auth path. - -Milestone 2: native user auth - -- Implement `ClientHello`/`ServerHello`/`UserAuth`/`AuthOk`. -- Support ssh-agent Ed25519 first. -- Verify against `authorized_keys`. -- Add `--auth=native|ssh|auto`. - -Milestone 3: default native auth - -- Make `auth_preference = "native,ssh"` default. -- Keep SSH fallback explicit and visible. -- Add benchmark gates for native cold auth. - -Milestone 4: forwarding - -- Add stream mux. -- Add `-L`, `-N`, and `-f`. -- Add `-R`. -- Add `-D` only after flow control is proven. - -Milestone 5: hardening - -- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state. -- Add hostile-network integration tests. -- Keep a public hardening checklist and threat model before public security claims. - -Milestone 6: workflow parity - -- Implement `dosh doctor`. -- Implement complete host-trust management. -- Implement private-key prompt flow. -- Implement forwarding policy diagnostics. -- Run daily-driver soak on macOS and Linux clients. - -## 16. Verification - -Native v1 is not complete until all are true: - -- Unknown host key fails by default unless TOFU is explicitly enabled. -- Known host key mismatch hard fails. -- Native Ed25519 auth succeeds via ssh-agent. -- Native Ed25519 auth succeeds via encrypted private key prompt. -- Removed authorized key can no longer authenticate. -- Restrictive unsupported authorized-key options fail closed. -- Replayed handshake packets are rejected. -- Replayed transport packets are rejected. -- Stale encrypted packets after reconnect are ignored, not fatal. -- Client IP/port change preserves the session. -- Native cold auth benchmark beats cold `ssh host true` on the same host. -- Cached attach remains near network RTT plus local render overhead. -- `-L` forwarding works without delaying terminal input. -- `-R` forwarding enforces bind and permission policy. -- `-N -L` forward-only mode does not allocate a PTY. -- `-f -N -L` backgrounds only after listener readiness. -- Multiple forwards in one command work. -- `dosh doctor` identifies UDP-blocked, auth-denied, host-key-mismatch, and - forwarding-denied states. -- Closing the laptop for at least 30 minutes does not kill the remote session. -- Three concurrent Dosh terminals remain independent unless explicitly named. -- Large forwarded transfers do not add visible terminal input lag. -- Fuzz targets run in CI. -- Threat model is updated with any accepted residual risks. - -## 17. Public Claim Gate - -Dosh may claim "native SSH replacement for Dosh-installed servers" only after: - -- Native auth is default on at least one real host. -- SSH fallback remains available. -- The verification checklist is green. -- The threat model is published. -- Benchmarks include raw samples for SSH cold, Dosh native cold, Dosh cached attach, - and Mosh startup. - -Dosh must not claim generic SSH compatibility unless it implements the SSH protocol. diff --git a/docs/PROTOCOL_VERSIONING.md b/docs/PROTOCOL_VERSIONING.md deleted file mode 100644 index 2976cd0..0000000 --- a/docs/PROTOCOL_VERSIONING.md +++ /dev/null @@ -1,40 +0,0 @@ -# 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 deleted file mode 100644 index 14091a6..0000000 --- a/docs/PUBLIC_READINESS.md +++ /dev/null @@ -1,220 +0,0 @@ -# Dosh Public Readiness - -Dosh's defensible public claim is fast terminal attach/reconnect plus native -encrypted forwarding on Dosh-installed servers. It should not claim generic SSH -compatibility unless it implements the SSH protocol, and public benchmark claims -must be reproducible outside the author's homelab. - -The plan for replacing the day-to-day SSH workflow with native Dosh authentication -and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, 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, -`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 and deeper fuzzing results are still open -launch evidence. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) -is green, Dosh's defensible public security claim remains fast encrypted native -attach/reconnect and forwarding with SSH bootstrap fallback, not generic SSH -compatibility. - -## Objective Benchmarks - -Run the same-host SSH comparison: - -```bash -make bench-docker-ssh -``` - -Run the Dosh, SSH, and Mosh comparison: - -```bash -make bench-docker-mosh -``` - -The Mosh-inclusive target builds one Docker image with OpenSSH, Mosh, `dosh-server`, -and `dosh-auth`. It uses the same generated key, localhost network path, SSH server, -and container for all samples. - -Reported metrics: - -| Metric | Meaning | -| --- | --- | -| `ssh_true_ms` | Time to run `ssh host true` with the same key/options. | -| `dosh_attach_ms` | Time for `dosh-client --attach-only` to render the first frame and detach on the configured path. With `--no-cache`, this is cold SSH bootstrap plus UDP attach. | -| `dosh_cached_attach_ms` | Time for a warmed Dosh client to attach using cached UDP credentials/tickets, render the first frame, and detach. This is the fast-path claim and should be approximately network RTT plus local process/render overhead. | -| `mosh_start_true_ms` | Time for `mosh host -- true` to bootstrap, run `true`, and exit. | - -These are not identical workloads. They are still useful because they measure the -startup path each tool must pay before useful remote work begins. Public numbers -must include the command, machine, OS, CPU, network path, and sample count. - -Do not cite cold `dosh_attach_ms` as the core speed claim. Cold Dosh still pays SSH -startup/authentication. Cite `dosh_cached_attach_ms` for repeat attach/reconnect -speed, and cite cold `dosh_attach_ms` only to show that fallback remains competitive -with ordinary SSH. - -## Feature Matrix - -| Feature | Mosh | Dosh now | Public status | -| --- | --- | --- | --- | -| SSH-based first authentication | yes | yes | ready | -| 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 | 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 | -| Fresh session by default | yes | yes | ready | -| 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 | 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 | 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 | 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, replay/reorder, and stream retransmit tested | -| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; policy and stream mux covered | -| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented over the same reliable stream mux | -| 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 | - -## SSH Config Inheritance - -Dosh intentionally delegates SSH parsing and authentication to OpenSSH. Bootstrap -uses the user's normal `ssh` command, so `~/.ssh/config` options such as `Host`, -`HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump`, and `UserKnownHostsFile` -continue to apply. - -Dosh also calls `ssh -G ` to infer the UDP target host when no `dosh_host` is -configured. To write explicit Dosh host entries from SSH aliases: - -```bash -dosh import-ssh homelab -``` - -This appends entries to `~/.config/dosh/hosts.toml` without trying to become an -OpenSSH config parser. - -## Optional Command Extensions - -Dosh can expose companion tools without taking a dependency on them. Command -extensions live in client or host config and expand only the first trailing word: - -```toml -[extensions.tm] -command = "tm {args}" -description = "Open the server-side tmux dashboard" -``` - -With that config, `dosh homelab tm` runs `tm` in the remote Dosh shell and -`dosh homelab tm dosh` runs `tm 'dosh'`. Removing the table removes the integration. -Host config can override a global extension, or disable it: - -```toml -[homelab.extensions.tm] -command = "/opt/tm/bin/tm {args}" - -[other-host.extensions.tm] -disabled = true -``` - -## Forwarding (Implemented) - -SSH forwarding cannot be copied by keeping the bootstrap SSH connection open, -because that would remove Dosh's fast reconnect advantage and would break after -roaming. Dosh forwarding therefore runs as native encrypted stream channels over the -Dosh transport, and is now implemented. - -| CLI | Meaning | -| --- | --- | -| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. | -| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server (loopback by default); client connects to target. | -| `dosh -D 1080 host` | SOCKS5 listener on the client; server connects to whatever the SOCKS request names. | - -Implemented: - -- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/ - `StreamEof`/`StreamClose` packet types. -- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal - frames, so bulk forwarding does not block PTY input/output. -- Ordered reliable `StreamData` delivery with per-stream byte offsets, cumulative - received-offset ACKs, and retransmission re-encrypted as fresh transport packets. -- Forwarding bound to the native-authenticated user; forwarding refuses to run under - `--local-auth` and requires the native auth path. -- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`, - loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key - `no-port-forwarding` and `permitopen=`. -- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only - after listeners are ready). - -Still open before claiming forwarding parity: - -- More real-host load soak. The integration suite already covers terminal-priority - behavior while a local forward is under blocked-stream pressure, plus hostile - replay/reorder and server-to-client stream retransmission after UDP loss. - -## Native v1 Verification Checklist Status - -This maps each item in `NATIVE_V1_SPEC.md` section 16 to its status based on what the -code in `src/` actually does. Done = implemented and exercised by tests or obvious -from code; in progress = partially implemented; pending = not yet implemented. - -| Spec section 16 item | Status | Evidence / note | -| --- | --- | --- | -| Unknown host key fails unless TOFU explicitly enabled | done | Client refuses `KnownHostStatus::Unknown` unless `trust_on_first_use`. | -| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. | -| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. | -| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. | -| 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` | 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. | -| 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 | done | `make soak-local` passed `sleep_roaming_soak_30m` for 1803.54s locally. Keep rerunning before tagged releases. | -| 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; 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: 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. - -## Before Public Launch - -- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green. -- Run `make soak-local` before each tagged release to refresh 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. -- Tune the native auth token bucket under abusive real-host traffic. -- Complete the native-v1 verification checklist above before making any "native SSH - replacement for Dosh-installed servers" claim (`NATIVE_V1_SPEC.md` section 17). diff --git a/docs/RELEASE_EVIDENCE_2026-06-20.md b/docs/RELEASE_EVIDENCE_2026-06-20.md deleted file mode 100644 index 11bfc5b..0000000 --- a/docs/RELEASE_EVIDENCE_2026-06-20.md +++ /dev/null @@ -1,127 +0,0 @@ -# Dosh Release Evidence - 2026-06-20 - -Runtime code benchmarked: `b44ff8e Improve release and benchmark tooling` - -Release docs/default cleanup after that benchmark removed personal host aliases from -public examples and test fixtures; it did not change release runtime paths. - -Environment: - -- Host: local homelab runner -- Benchmark harness: `make bench-docker-mosh` -- Container SSH server: OpenSSH 9.6p1 on Ubuntu -- Same container, key, DNS, loopback, and Dosh server path for all Docker samples - -## Release Artifacts - -Generated locally with: - -```bash -sh scripts/package-release.sh -``` - -Artifacts: - -| artifact | sha256 | -| --- | --- | -| `dosh-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` | -| `dosh-0.1.0-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` | -| `dosh-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` | -| `dosh-0.1.0-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` | - -The installer verifies `.sha256` when the sidecar is published. - -## Docker SSH/Mosh Benchmark - -Command: - -```bash -make bench-docker-mosh -``` - -### Cold SSH Bootstrap - -| metric | n | min ms | median ms | p95 ms | mean ms | max ms | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `ssh_true_ms` | 3 | 216.58 | 220.27 | 224.64 | 220.66 | 225.12 | -| `dosh_attach_ms` | 3 | 232.37 | 235.56 | 239.13 | 235.82 | 239.53 | - -Raw samples: - -- `ssh_true_ms`: [216.58, 220.27, 225.12] -- `dosh_attach_ms`: [235.56, 232.37, 239.53] - -Gate: `dosh_attach_ms avg 235.82ms <= ssh avg 220.66ms + 500.00ms` - -### ControlMaster SSH Bootstrap - -| metric | n | min ms | median ms | p95 ms | mean ms | max ms | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `ssh_true_ms` | 3 | 7.29 | 7.50 | 47.51 | 22.25 | 51.95 | -| `dosh_attach_ms` | 3 | 16.62 | 17.01 | 17.77 | 17.16 | 17.85 | - -Raw samples: - -- `ssh_true_ms`: [51.95, 7.29, 7.50] -- `dosh_attach_ms`: [17.01, 16.62, 17.85] - -Gate: `dosh_attach_ms avg 17.16ms <= ssh avg 22.25ms + 500.00ms` - -### Native Cold Auth - -| metric | n | min ms | median ms | p95 ms | mean ms | max ms | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `ssh_true_ms` | 5 | 215.72 | 222.68 | 227.05 | 221.32 | 227.82 | -| `dosh_cold_native_ms` | 5 | 8.63 | 8.84 | 9.16 | 8.88 | 9.23 | - -Raw samples: - -- `ssh_true_ms`: [216.40, 223.99, 227.82, 222.68, 215.72] -- `dosh_cold_native_ms`: [8.84, 8.63, 9.23, 8.88, 8.84] - -Gate: `dosh_cold_native_ms avg 8.88ms <= ssh avg 221.32ms + 0.00ms` - -### Cached Attach - -| metric | n | min ms | median ms | p95 ms | mean ms | max ms | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `dosh_cached_attach_ms` | 10 | 8.07 | 8.96 | 9.83 | 8.98 | 9.84 | - -Raw samples: - -- `dosh_cached_attach_ms`: [8.07, 9.09, 9.02, 8.66, 8.71, 8.90, 8.45, 9.84, 9.25, 9.82] - -Gate: `dosh_cached_attach_ms avg 8.98ms <= 25.00ms` - -### Mosh Comparison - -| metric | n | min ms | median ms | p95 ms | mean ms | max ms | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `ssh_true_ms` | 3 | 216.48 | 218.93 | 219.49 | 218.32 | 219.55 | -| `dosh_attach_ms` | 3 | 229.59 | 233.00 | 233.31 | 231.98 | 233.35 | -| `mosh_start_true_ms` | 3 | 527.13 | 563.34 | 573.09 | 554.89 | 574.18 | - -Raw samples: - -- `ssh_true_ms`: [219.55, 216.48, 218.93] -- `dosh_attach_ms`: [233.00, 229.59, 233.35] -- `mosh_start_true_ms`: [563.34, 527.13, 574.18] - -## Soak And Fuzz Evidence - -- `cargo test`: `153 passed, 1 ignored` -- `make soak-local`: `sleep_roaming_soak_30m ... ok`, finished in `1803.54s` -- `make fuzz-smoke`: passed all configured targets for 20s each: - `packet_decode`, `from_body`, `authorized_keys`, `known_hosts`, - `handshake_structs`, `attach_ticket` - -## Public Claim Boundary - -This evidence supports: - -- Dosh cached attach and native auth are much faster than cold SSH/Mosh startup in - this benchmark environment. -- Dosh remains mosh-shaped, not mosh-compatible. -- Dosh does not claim generic SSH replacement compatibility. -- Native v1 security still needs external review before making hard public security - claims. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md deleted file mode 100644 index df836db..0000000 --- a/docs/THREAT_MODEL.md +++ /dev/null @@ -1,241 +0,0 @@ -# Dosh Threat Model - -This is the published threat model for Dosh native v1, derived from -`docs/NATIVE_V1_SPEC.md` sections 4-6. It states the assets Dosh protects, the -attackers it does and does not defend against, the security properties Dosh claims -relative to SSH, the cryptographic building blocks actually in use, and an honest -list of accepted residual risks and known gaps. - -Dosh is a remote-login transport for Dosh-installed servers. It is intended to -replace the day-to-day `ssh host` workflow for terminals and TCP forwarding while -keeping OpenSSH as a recovery and bootstrap fallback. Dosh is **not** an -RFC-compatible SSH implementation and does not claim SSH's entire protocol security -surface. It claims security that is **equivalent to, and in some respects stronger -than, SSH for the Dosh terminal and forwarding use case** on hosts running -`dosh-server`. - -## 1. Assets - -Dosh protects: - -- **Terminal session contents.** Keystrokes, command output, and the authoritative - screen state for every named or generated session. -- **Forwarded TCP streams.** Bytes carried over `-L`, `-R`, and `-D` channels. -- **User authentication credentials.** The user's SSH/Dosh private keys and any - ssh-agent identities. Dosh never sees private key material in plaintext on the - wire; signatures are produced locally or by the agent. -- **Server-issued credentials.** Session keys, `ClientId` association state, - server-sealed attach tickets, and the client-held attach-ticket PSK. -- **Server identity.** The persistent Dosh host key (`~/.config/dosh/host_key`) and - the server secret used to seal attach tickets and derive bootstrap material. -- **Host-trust state.** The client's pinned known-hosts file - (`~/.config/dosh/known_hosts`). -- **Authorization policy.** `~/.ssh/authorized_keys` / - `~/.config/dosh/authorized_keys` and the forwarding policy they encode. - -## 2. Attackers - -### In scope (Dosh must defend against these) - -- **Passive network observer.** May record all UDP traffic between client and - server. -- **Active network attacker.** May spoof, drop, replay, reorder, or modify any - packet, and may attempt to inject forged packets in either direction. -- **NAT rebinding / roaming.** Client source IP and port may change mid-session, - including across sleep, network switch, and NAT timeout. -- **Stolen attach-ticket cache without the user's private key.** An attacker who - reads a client cache copies a server-sealed ticket plus its PSK but does not have - the user's SSH private key. -- **Server restart and key rotation.** Stale session keys, tickets, and replay - state must not be usable after the server rotates its secret or host key. -- **Malicious unauthenticated client flooding auth attempts.** A peer that has no - authorized key tries to exhaust server resources or guess credentials. -- **Compromised low-privilege local user on a shared client.** A different local - user attempts to read Dosh credential caches on the same machine. - -### Out of scope (explicitly not defended against) - -- **Compromised client machine.** If the endpoint running `dosh-client` is owned by - the attacker, the attacker has the user's keys and terminal. -- **Compromised server account.** If the login account on the server is owned, the - attacker already has the shell Dosh would have given them. -- **Malicious kernel, terminal emulator, or PTY implementation** on either side. -- **A server that was legitimately authorized and later turns malicious.** Host-key - pinning detects a *substituted* server, not a trusted server that decides to - misbehave. - -These exclusions match SSH's own boundaries: SSH likewise cannot protect a -compromised endpoint or a malicious authorized peer. - -## 3. Security Properties Claimed vs SSH - -| Property | SSH | Dosh native v1 | Notes | -| --- | --- | --- | --- | -| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. | -| User authentication by private-key possession | yes | yes | Ed25519, 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/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. | - -### Where Dosh aims to *exceed* SSH for this use case - -- **Tighter transcript binding.** A user-auth signature binds both ephemeral keys, - both randoms, the server host key, requested user/session/mode/terminal size, - selected algorithms, and protocol version into one transcript. This forecloses - cross-protocol and partial-replay confusion classes for the narrow Dosh surface. -- **Smaller attack surface.** Dosh deliberately omits the full SSH transport/channel - machinery, arbitrary subsystems, X11, SFTP/SCP, and forced-command subsystems. - Fewer features means fewer parsers and fewer reachable states. -- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable - known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail - mismatch path, rather than the looser default TOFU behavior most SSH clients ship. -- **Modern primitives only.** Ed25519 and X25519 by default; 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. - -## 4. Cryptographic Building Blocks (as implemented) - -These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and -`src/protocol.rs`, not just the spec. - -- **Key exchange:** X25519 ephemeral-ephemeral (`x25519-dalek`). The shared secret - is checked for contributory behaviour; a non-contributory result is rejected. -- **Handshake/transport KDF:** HKDF-SHA256 (`hkdf`), salted with the SHA-256 of the - serialized `ClientHello` and `ServerHello`, binding traffic keys to the transcript. -- **AEAD:** ChaCha20-Poly1305 (`chacha20poly1305`) for every encrypted packet and - for sealed attach tickets. Nonces are derived as `direction || sequence`, giving a - unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is - not selectable today. -- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript. -- **User-auth signatures:** Ed25519, 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. -- **Hashing/transcript:** SHA-256 (`sha2`). -- **Randomness:** OS CSPRNG via `rand::thread_rng()` / `OsRng`. - -No homegrown ciphers, MACs, padding schemes, or key derivation are used. No nonce or -key pair is reused within a direction. - -## 5. How the Properties Are Enforced (handshake and transport) - -- **Server authentication.** `ServerHello` carries the host public key and an Ed25519 - signature over `dosh/native/server-hello/v1 || ClientHello || ServerHello(unsigned)`. - The client verifies this signature *and* checks the host key against its pinned - known-hosts entry before sending user auth or accepting terminal bytes. Unknown - keys are refused unless TOFU is enabled; mismatches hard-fail with both - fingerprints and the file path. -- **User authentication.** `UserAuth` carries an Ed25519 signature over - `dosh/native/user-auth/v1 || ClientHello || ServerHello || UserAuth(unsigned)`. The - server looks the public key up in `authorized_keys`, enforces authorized-key - options, then verifies the signature. A removed key can no longer authenticate. -- **Authorization options.** `from=` (with CIDR, glob, and negation), - `no-port-forwarding`, and `permitopen=` are enforced. `command=` is rejected for - native terminal login. Any unrecognized restrictive option fails closed rather than - being silently ignored. -- **Forwarding policy.** The server enforces `allow_tcp_forwarding`, - `allow_remote_forwarding`, and a loopback-only default for remote binds - (`allow_remote_non_loopback_bind`), in addition to per-key `permitopen=` / - `no-port-forwarding`. -- **Replay protection.** A 128-wide sliding window over the per-direction packet - counter rejects duplicates and stale sequences. Sequence 0 is never accepted. -- **Stale-key tolerance.** Each encrypted packet carries a `session_key_id`; packets - under an old key are dropped as "stale or wrong session key id" rather than treated - as fatal decrypt failures, so reconnect after rotation is non-destructive. -- **Roaming.** The server keys clients by `ClientId` and session key id, not by - source address, and updates the endpoint after any valid encrypted packet from a - new address — without weakening authentication, because the packet must still - decrypt and verify. - -## 6. Accepted Residual Risks and Known Gaps - -These are stated openly so the public claim gate (`NATIVE_V1_SPEC.md` section 17) can -be evaluated honestly. Items here are *not* yet "green". - -### Accepted residual risks (by design) - -- **Attach tickets prove recent server-issued possession, not fresh private-key - possession.** A stolen client cache containing both the sealed ticket and its PSK - can attach until the ticket expires (default TTL 24h, configurable down to zero) or - until the server rotates its secret/host key or the user key is removed. This is the - deliberate speed trade. It is bounded by TTL, scoped to host/user/session/mode, and - can be disabled. SSH ControlMaster has an analogous live-socket exposure. -- **Local cache confidentiality relies on filesystem permissions.** Host keys, - server secret, known-hosts, and credential caches are written `0600`. A - same-machine attacker who can already read another user's `0600` files (e.g. via - root) is out of scope, as with SSH's `~/.ssh`. -- **A trusted server that turns malicious is not detected.** Host-key pinning - detects substitution, not betrayal by an already-authorized server. This matches - SSH. - -### Known gaps / work in progress (must close before the public claim) - -- **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 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. -- **Forwarded stream data uses ordered retransmission.** Streams carry byte offsets, - cumulative received-offset ACKs, and retransmit unacknowledged chunks as fresh - encrypted transport packets. Hostile-network tests cover replay/reorder and - server-to-client stream recovery after deliberate UDP loss; broader real-host load - soak remains launch evidence. -- **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 third-party audit claim.** Dosh maintains a public threat model and hardening - checklist, but should not market itself as externally audited unless that actually - happens. - -### Auth posture - -Native auth is **opt-in alongside SSH fallback**. `auth_preference` defaults to -`native,ssh`: Dosh tries the native authenticated path first and falls back to SSH -bootstrap explicitly and visibly when native auth is disabled, unavailable, or -rejected. Native auth failure never silently degrades to an unauthenticated mode. -Forwarding (`-L`/`-R`/`-D`) requires the native authenticated path and refuses to run -under `--local-auth`. - -## 7. Verification and Public-Claim Status - -Dosh may claim "native SSH replacement for Dosh-installed servers" only after the -conditions in `NATIVE_V1_SPEC.md` section 17 are met: native auth default on a real -host, SSH fallback available, the section 16 checklist green, this threat model -published, and benchmarks with raw samples for SSH cold, Dosh native cold, Dosh -cached attach, and Mosh startup. - -Current status: this threat model is published (this document). The verification -checklist is **not yet fully green** — see the item-by-item status table in -`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known -gaps in section 6 above. Until the gaps close, Dosh's defensible public claim remains -**fast, encrypted native attach/reconnect and forwarding with SSH bootstrap -fallback** on Dosh-installed servers, not generic SSH compatibility or a third-party -audited security product. diff --git a/fuzz/README.md b/fuzz/README.md deleted file mode 100644 index ad52255..0000000 --- a/fuzz/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# dosh fuzz targets - -cargo-fuzz / libFuzzer harnesses for the `dosh` parsers and handshake verifiers -(spec milestone 5 / §16: "Fuzz packet parsing, authorized-key parsing, -known-host parsing, and handshake state", "Fuzz targets run in CI"). - -This is a **standalone crate** (its own `[workspace]`) so it never affects the -main crate's `cargo build` / `cargo test` / `cargo fmt --check`. - -## Targets - -| Target | Parser(s) exercised | -| --- | --- | -| `packet_decode` | `protocol::Header::parse`, `protocol::decode`, `protocol::decrypt_body` | -| `from_body` | `protocol::from_body::` for every protocol & native wire struct | -| `authorized_keys` | `native::parse_authorized_keys`, `native::parse_ssh_ed25519_public_blob` | -| `known_hosts` | `native::parse_known_hosts`, `native::parse_host_public_key_line` | -| `handshake_structs` | handshake struct decode + `verify_server_hello`, `user_auth_transcript`, `verify_native_user_auth` | -| `attach_ticket` | `auth::open_attach_ticket`, `auth::verify_attach_ticket`, `auth::decode_bootstrap` | - -Every target's objective is the same: **no panics on any input** (a panic on -untrusted bytes is a robustness/DoS bug per threat model §5). - -## Prerequisites - -```sh -rustup toolchain install nightly -cargo install cargo-fuzz -``` - -cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`). - -## Run - -From the repository root: - -```sh -# 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 - -# Run a single target indefinitely -cargo +nightly fuzz run --fuzz-dir fuzz packet_decode - -# Short, CI-style smoke run of one target (10 seconds) -cargo +nightly fuzz run --fuzz-dir fuzz packet_decode -- -max_total_time=10 -``` - -Or from inside `fuzz/`: - -```sh -cd fuzz -cargo +nightly fuzz run packet_decode -- -max_total_time=10 -``` - -## CI - -`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly + -cargo-fuzz and runs each target. 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/llms.txt b/llms.txt deleted file mode 100644 index 1ddb6b7..0000000 --- a/llms.txt +++ /dev/null @@ -1,201 +0,0 @@ -# dosh (Dormant Shell) - -> dosh is a low-latency remote terminal for homelab/personal servers. It is mosh-shaped -> but not a mosh clone: `dosh-server` is a resident daemon that keeps terminal sessions -> hot, and the client reconnects over encrypted UDP — so attach and reconnect are -> near-instant (~3 ms of local overhead + one network RTT). SSH is used once to -> establish trust; after that, repeat attaches skip SSH entirely. It also does SSH-style -> TCP port forwarding (`-L`/`-R`/`-D`) over the same encrypted transport, which is what -> makes back-and-forth client↔server homelab comms easy. - -This file orients an AI agent (or a human) on what dosh is, what it can do, and how to -drive it. It is intentionally self-contained. For deeper detail, see the linked docs at -the bottom. - -## What dosh is for - -Use dosh instead of `ssh`/`mosh` for **interactive shells and TCP forwarding** to a -server where you control both ends and have installed `dosh-server` (typically a homelab -box, VPS, or workstation). It shines when you: - -- want a terminal that survives laptop sleep, Wi-Fi changes, and NAT rebinding, and - resumes instantly instead of hanging; -- reconnect to the same box many times a day and don't want to pay SSH startup each time; -- need to reach services on the server from your laptop (or vice-versa) without standing - up a VPN. - -dosh is **not** a drop-in for every SSH use. It does not do `scp`/`sftp` file transfer, -X11, or act as an `sshd` for arbitrary SSH clients. Keep `ssh` installed for those. - -## Core capabilities - -- **Encrypted UDP terminal transport** — AEAD-encrypted, with packet sequencing, a - sliding replay window, ACKs, and server-side retransmit of unacked output. -- **Resident server + hot sessions** — `dosh-server` runs as a daemon; named sessions - (and a prewarmed `default`) stay alive across client disconnects. -- **Fast attach / reconnect** — see "Fast path order" below; cached attach is ~one RTT. -- **Roaming** — the session follows the client across IP/port changes. -- **Named & shared sessions** — reattach the same persistent terminal from multiple - clients; optional **view-only** clients. -- **TCP port forwarding** — local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) over - the encrypted transport, with per-stream flow control and terminal-priority - scheduling (bulk transfers don't lag your keystrokes). -- **Native UDP auth** — Ed25519 user auth via ssh-agent or an (optionally encrypted) - OpenSSH key, verified against `authorized_keys`. Falls back to SSH bootstrap when - native auth isn't available. SSH host config (`HostName`, `User`, `Port`, - `IdentityFile`, `ProxyJump`, etc.) is honored. -- **Host-key pinning** — TOFU/known-hosts with hard-fail on mismatch. -- **Speculative local echo** — optional predictive echo for laggy links (display-only; - real input is always sent to the server). -- **Ops commands** — `doctor`, `sessions`, `trust`, `import-ssh`, `update`. - -## Quickstart - -Install the server on each box you want to reach (default UDP port `50000`): - -```bash -curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ - | DOSH_PORT=50000 sh -s -- server -``` - -Install the client (macOS/Linux), then attach: - -```bash -curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ - | DOSH_SERVER=homelab DOSH_HOST=homelab.example.com DOSH_PORT=50000 sh -s -- client - -dosh homelab # fresh interactive shell -dosh homelab uptime # run one command -dosh --session work homelab # named, persistent, reattachable session -``` - -- Detach (leave the server session running): **Ctrl-]**. -- End the session: type `exit` in the remote shell. -- If UDP stalls, dosh keeps the terminal open, sends keepalives, and ticket-reconnects. - -## Client↔server homelab comms (the back-and-forth) - -Forwarding is the key to "make homelab comms easy." Three directions: - -| Command | Direction | Effect | -| --- | --- | --- | -| `dosh -L [bind:]LPORT:THOST:TPORT host` | pull server→you | A listener on **your machine** (`bind`, default localhost) forwards to `THOST:TPORT` reached **from the server**. | -| `dosh -R [bind:]LPORT:THOST:TPORT host` | push you→server | A listener on **the server** (loopback by default) forwards to `THOST:TPORT` reached **from your machine**. | -| `dosh -D [bind:]LPORT host` | SOCKS via server | A SOCKS5 proxy on **your machine**; traffic egresses **from the server**. | - -Concrete homelab patterns: - -```bash -# Reach the homelab's internal Grafana (server-side :3000) from your laptop browser: -dosh -L 3000:127.0.0.1:3000 homelab # open http://localhost:3000 - -# Reach a DB that only listens on the homelab LAN: -dosh -L 5432:10.0.0.5:5432 homelab # psql -h 127.0.0.1 -p 5432 - -# Let the homelab hit a dev server running on your laptop (e.g. a webhook target): -dosh -R 9000:127.0.0.1:8080 homelab # homelab curls http://127.0.0.1:9000 - -# Route browser traffic out through the homelab's network: -dosh -D 1080 homelab # SOCKS5 proxy at 127.0.0.1:1080 - -# Forward-only, no shell; multiple forwards; background after listeners are up: -dosh -N -L 3000:127.0.0.1:3000 -L 5432:10.0.0.5:5432 homelab -dosh -f -N -L 3000:127.0.0.1:3000 homelab -``` - -Server policy controls forwarding: `allow_tcp_forwarding`, `allow_remote_forwarding`, -`allow_remote_non_loopback_bind`, and per-key `permitopen=`/`no-port-forwarding` in -`authorized_keys`. Remote listeners bind to loopback unless explicitly allowed. - -## Fast path order (why it's quick) - -The client tries the cheapest valid path first: - -1. **UDP resume** — existing client id + session key; one encrypted UDP round-trip. -2. **UDP attach ticket** — cached server-issued ticket; one round-trip, no SSH. -3. **Native UDP auth** — Ed25519 handshake (ssh-agent/key) when enabled. -4. **SSH bootstrap** — `ssh user@host dosh-auth …` once, then a UDP attach. - -Measured locally (loopback, release): cached attach ≈ **3 ms**, cold native auth ≈ 9 ms. -Over a real link, add one RTT. See `docs/BENCHMARKS.md`. - -## Architecture (1-minute model) - -- **dosh-server** — single UDP socket on one port; a session table keyed by name; one - PTY per named session; per-session terminal screen state (vt100) for snapshots/diffs; - a per-session client table; encrypted UDP protocol; a small SSH-invoked `dosh-auth` - helper. Abandoned (clientless, non-prewarmed) sessions and their shells are reaped - after a grace period; prewarmed sessions stay hot. -- **dosh-client** — raw-mode terminal; local credential cache; tries resume/ticket/native - before SSH; forwards PTY I/O; reconnect/roaming state machine; optional predictive echo. -- **Binaries** — `dosh-server`, `dosh-client` (symlinked as `dosh`), `dosh-auth` - (SSH-invoked trust helper), `dosh-bench` (benchmarks). - -## Security model (summary) - -- First trust via SSH; thereafter encrypted Dosh transport. Native auth available. -- KEX X25519; AEAD ChaCha20-Poly1305; KDF HKDF-SHA256; host & user auth Ed25519; - SHA-256 transcript binding. Per-direction, per-sequence nonces; replay window. -- Host keys pinned in `~/.config/dosh/known_hosts`; mismatch hard-fails. -- User auth against `~/.ssh/authorized_keys` (+ optional `~/.config/dosh/authorized_keys`); - removed keys can't authenticate; unsupported restrictive options fail closed. -- Forward secrecy from ephemeral X25519; attach tickets are server-sealed and scoped. - -dosh claims security **equivalent to, and in places stronger than, SSH for the dosh -terminal/forwarding use case** — not full SSH-protocol parity. Read `docs/THREAT_MODEL.md` -for the honest stance, residual risks, and what's still pending before public claims. - -## Configuration - -- **Client** `~/.config/dosh/client.toml` — `auth_preference` (e.g. `"native,ssh"`), - `trust_on_first_use`, `identity_files`, `use_ssh_agent`, `forward_agent`, `send_env`, - `set_env`, `dosh_port`, `default_session`, `predict`, `reconnect_timeout_secs`, - `credential_cache`, `known_hosts`. -- **Hosts** `~/.config/dosh/hosts.toml` — per-alias `[name]` with `ssh`, `dosh_host`, - `port`, `user`, `default_command`, `predict`. Generate from SSH aliases with - `dosh import-ssh `. -- **Server** `~/.config/dosh/server.toml` — `port`, `bind`, `shell`, `prewarm_sessions`, - `native_auth`, `host_key`, `authorized_keys`, `attach_ticket_ttl_secs`, - `client_timeout_secs`, `allow_tcp_forwarding`, `allow_remote_forwarding`, - `allow_remote_non_loopback_bind`, `allow_agent_forwarding`, `accept_env`, - `native_auth_rate_limit_per_minute`. - -Paths: host key `~/.config/dosh/host_key`; credential cache -`~/.local/share/dosh/credentials/`. - -## Operating & diagnostics - -```bash -dosh doctor homelab # host resolution, trust state, UDP reachability, server - # version, usable keys, auth result, forwarding policy -dosh sessions homelab # list live sessions -dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback) -dosh trust --remove homelab -dosh import-ssh homelab # write a hosts.toml entry from an SSH alias -dosh update # update the installed client -``` - -If something fails, `dosh doctor ` is the first stop — every public error is meant -to be actionable (host trust, auth failure, UDP blocked, forwarding denied, version -mismatch, server unavailable). - -## For agents driving dosh - -- Run one command and exit: `dosh `. Render one frame and detach: - `dosh --attach-only `. Both are non-interactive-friendly. -- A real terminal size is needed for full-screen rendering; with no TTY the client falls - back to 80×24. Pass `-v`/`-vv` for timing/diagnostic logs on stderr. -- Prefer `--session ` to reattach a known session; bare `dosh ` opens a - fresh, uniquely-named session each time. -- Never assume file transfer or X11 — use `ssh`/`scp` for those. -- Diagnostics are scriptable via `dosh doctor `. - -## Reference docs - -- `README.md` — overview, install, develop, performance rules. -- `docs/NATIVE_V1_SPEC.md` — the native auth + forwarding v1 contract and verification - checklist (with current status). -- `docs/THREAT_MODEL.md` — security claims, threat model, residual risks. -- `docs/PUBLIC_READINESS.md` — feature matrix and §16 verification status. -- `docs/BENCHMARKS.md` — how to benchmark; metric definitions; sample numbers. -- `SPEC.md` — protocol/wire details. diff --git a/scripts/bench-local.sh b/scripts/bench-local.sh index 75db156..6ff7b34 100755 --- a/scripts/bench-local.sh +++ b/scripts/bench-local.sh @@ -183,8 +183,7 @@ server_pid="" # meaningful here. The cold fresh-process reconnect fast path is the attach # ticket (measured above). Roaming resume is validated by the integration test # `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for -# scenarios that keep a live session out-of-band (e.g. remote soak tests); see -# docs/BENCHMARKS.md. +# scenarios that keep a live session out-of-band (e.g. remote soak tests). rm -rf "$home/.local/share/dosh/credentials" write_client_config true start_server diff --git a/scripts/package-release.sh b/scripts/package-release.sh index b694aa9..48a8074 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -37,15 +37,26 @@ else fi stage="$out_dir/stage/dosh" -cargo build --release +if [ "$os" = "windows" ]; then + cargo build --release --bin dosh-client --bin dosh-bench +else + cargo build --release +fi rm -rf "$stage" mkdir -p "$stage/bin" "$out_dir" for bin in dosh-client dosh-server dosh-auth dosh-bench; do if [ -f "target/release/$bin" ]; then install -m 0755 "target/release/$bin" "$stage/bin/$bin" + elif [ -f "target/release/$bin.exe" ]; then + install -m 0755 "target/release/$bin.exe" "$stage/bin/$bin.exe" fi done +if [ -f "$stage/bin/dosh-client.exe" ]; then + cp "$stage/bin/dosh-client.exe" "$stage/bin/dosh.exe" +elif [ -f "$stage/bin/dosh-client" ]; then + cp "$stage/bin/dosh-client" "$stage/bin/dosh" +fi printf '%s\n' "$version" >"$stage/VERSION" if [ "$os" = "windows" ]; then diff --git a/scripts/tui-harness.sh b/scripts/tui-harness.sh new file mode 100755 index 0000000..d06e1a9 --- /dev/null +++ b/scripts/tui-harness.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +for test_name in \ + live_output_forwards_terminal_control_sequences \ + tui_control_sequences_survive_transport_verbatim \ + large_tui_paint_is_delivered_in_mtu_safe_frames \ + resume_snapshot_preserves_alternate_screen_mode +do + cargo test --test integration_smoke "$test_name" -- --nocapture +done diff --git a/src/auth.rs b/src/auth.rs index 8618f09..1774dbe 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,6 +7,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; +#[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::time::{SystemTime, UNIX_EPOCH}; @@ -82,10 +83,11 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> { fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let secret = crypto::random_32(); - let mut file = fs::OpenOptions::new() - .create_new(true) - .write(true) - .mode(0o600) + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options .open(&path) .with_context(|| format!("create {}", path.display()))?; file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?; diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 29a789c..df7bfc2 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -40,8 +40,9 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream, UdpSocket, UnixStream}; -use tokio::signal::unix::{SignalKind, signal}; +#[cfg(unix)] +use tokio::net::UnixStream; +use tokio::net::{TcpListener, TcpStream, UdpSocket}; use tokio::sync::mpsc; const STREAM_INITIAL_WINDOW: usize = 1024 * 1024; @@ -200,6 +201,9 @@ async fn main() -> Result<()> { if args.server.as_deref() == Some("selftest") { return run_selftest_command(&config, &args).await; } + if matches!(args.server.as_deref(), Some("recover" | "repair")) { + return run_recover_command(&config, &args).await; + } if args.server.as_deref() == Some("forward") { args = rewrite_forward_command(args)?; } @@ -692,6 +696,19 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> run_doctor_for_host(config, args, &requested).await } +async fn run_recover_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { + if args.command.len() != 1 { + return Err(anyhow!("usage: dosh recover ")); + } + let requested = args.command[0].clone(); + println!("Dosh recovery for {requested}"); + let removed = clear_cached_credentials(&config.credential_cache, &requested) + .context("clear cached attach credentials")?; + println!("[ok] removed {removed} cached credential(s)"); + println!("[info] host trust was left unchanged"); + run_doctor_for_host(config, args, &requested).await +} + async fn run_doctor_for_host( config: &dosh::config::ClientConfig, args: &Args, @@ -2573,6 +2590,9 @@ async fn run_terminal( forward_only: bool, agent_sock: Option, ) -> Result<()> { + #[cfg(not(unix))] + let _ = &agent_sock; + let _raw = if forward_only { None } else { @@ -2589,7 +2609,9 @@ async fn run_terminal( // instead of waiting up to one `resize_tick`. The 250ms poll below stays as a // fallback for environments where the signal doesn't fire. `signal()` can fail // (rare), in which case we rely on the poll. - let mut winch = signal(SignalKind::window_change()).ok(); + #[cfg(unix)] + let mut winch = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()).ok(); let mut frame_buffer = FrameBuffer::default(); // Resolve the prediction display policy (off / experimental / always). An // env var wins for ad-hoc tuning; otherwise the client config's @@ -2618,6 +2640,7 @@ async fn run_terminal( // Write halves for server-initiated SSH-agent streams, spliced into the local // unix agent socket. Kept separate from the TCP `stream_writers` so the // existing TCP forwarding paths are untouched. + #[cfg(unix)] let mut agent_writers: HashMap = HashMap::new(); let mut pending_socks_replies: HashSet = HashSet::new(); let mut opened_streams: HashSet = HashSet::new(); @@ -2689,10 +2712,13 @@ async fn run_terminal( } } _ = async { + #[cfg(unix)] match winch.as_mut() { Some(w) => { w.recv().await; } None => std::future::pending::<()>().await, } + #[cfg(not(unix))] + std::future::pending::<()>().await } => { maybe_send_resize(&socket, addr, &cred, &mut send_seq, &mut last_size).await?; } @@ -2838,53 +2864,89 @@ async fn run_terminal( // actually opted in (agent_sock is Some); otherwise reject, // so a server cannot reach our agent without consent. if open.target_host == AGENT_STREAM_SENTINEL { - let Some(sock_path) = agent_sock.clone() else { + #[cfg(not(unix))] + { send_stream_open_reject( - &socket, addr, &cred, &mut send_seq, open.stream_id, - "agent forwarding not enabled by client".to_string(), - ).await?; - continue; - }; - match UnixStream::connect(&sock_path).await { - Ok(stream) => { - let (mut reader, writer) = stream.into_split(); - agent_writers.insert(open.stream_id, writer); - opened_streams.insert(open.stream_id); - stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW); - stream_next_send_offset.entry(open.stream_id).or_insert(0); - stream_next_recv_offset.entry(open.stream_id).or_insert(0); - send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?; - let forward_tx = remote_forward_tx.clone(); - tokio::spawn(async move { - let mut buf = [0u8; 16 * 1024]; - loop { - match reader.read(&mut buf).await { - Ok(0) => break, - Ok(n) => { - if forward_tx - .send(ForwardEvent::Data { - stream_id: open.stream_id, - bytes: buf[..n].to_vec(), - }) - .await - .is_err() - { - return; - } - } - Err(_) => break, - } - } - let _ = forward_tx.send(ForwardEvent::Close { - stream_id: open.stream_id, - }).await; - }); - } - Err(err) => { + &socket, + addr, + &cred, + &mut send_seq, + open.stream_id, + "agent forwarding is not supported on this client platform" + .to_string(), + ) + .await?; + } + #[cfg(unix)] + { + let Some(sock_path) = agent_sock.clone() else { send_stream_open_reject( - &socket, addr, &cred, &mut send_seq, open.stream_id, - format!("connect local agent: {err}"), - ).await?; + &socket, + addr, + &cred, + &mut send_seq, + open.stream_id, + "agent forwarding not enabled by client".to_string(), + ) + .await?; + continue; + }; + match UnixStream::connect(&sock_path).await { + Ok(stream) => { + let (mut reader, writer) = stream.into_split(); + agent_writers.insert(open.stream_id, writer); + opened_streams.insert(open.stream_id); + stream_send_credit + .insert(open.stream_id, STREAM_INITIAL_WINDOW); + stream_next_send_offset.entry(open.stream_id).or_insert(0); + stream_next_recv_offset.entry(open.stream_id).or_insert(0); + send_stream_open_ok( + &socket, + addr, + &cred, + &mut send_seq, + open.stream_id, + ) + .await?; + let forward_tx = remote_forward_tx.clone(); + tokio::spawn(async move { + let mut buf = [0u8; 16 * 1024]; + loop { + match reader.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + if forward_tx + .send(ForwardEvent::Data { + stream_id: open.stream_id, + bytes: buf[..n].to_vec(), + }) + .await + .is_err() + { + return; + } + } + Err(_) => break, + } + } + let _ = forward_tx + .send(ForwardEvent::Close { + stream_id: open.stream_id, + }) + .await; + }); + } + Err(err) => { + send_stream_open_reject( + &socket, + addr, + &cred, + &mut send_seq, + open.stream_id, + format!("connect local agent: {err}"), + ) + .await?; + } } } continue; @@ -3003,9 +3065,12 @@ async fn run_terminal( for bytes in &writes { let _ = writer.write_all(bytes).await; } - } else if let Some(writer) = agent_writers.get_mut(&stream_id) { - for bytes in &writes { - let _ = writer.write_all(bytes).await; + } else { + #[cfg(unix)] + if let Some(writer) = agent_writers.get_mut(&stream_id) { + for bytes in &writes { + let _ = writer.write_all(bytes).await; + } } } // Always return flow-control credit so a stream whose local @@ -3063,6 +3128,7 @@ async fn run_terminal( stream_next_recv_offset.remove(&close.stream_id); stream_recv_pending.remove(&close.stream_id); stream_writers.remove(&close.stream_id); + #[cfg(unix)] agent_writers.remove(&close.stream_id); } _ => {} @@ -3114,6 +3180,7 @@ async fn run_terminal( stream_next_recv_offset.remove(&stream_id); stream_recv_pending.remove(&stream_id); stream_writers.remove(&stream_id); + #[cfg(unix)] agent_writers.remove(&stream_id); send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?; } @@ -4607,11 +4674,46 @@ fn emit_status(_bytes: &[u8]) -> Result<()> { } fn cache_path(root: &str, server: &str, session: &str, mode: &str) -> std::path::PathBuf { - let safe = format!("{server}_{session}_{mode}") + let safe = cache_key(server, session, mode); + expand_tilde(root).join(format!("{safe}.bin")) +} + +fn cache_key(server: &str, session: &str, mode: &str) -> String { + format!("{server}_{session}_{mode}") .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect::(); - expand_tilde(root).join(format!("{safe}.bin")) + .collect::() +} + +fn cache_server_prefix(server: &str) -> String { + format!("{server}_") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() +} + +fn clear_cached_credentials(root: &str, server: &str) -> Result { + let dir = expand_tilde(root); + let Ok(entries) = fs::read_dir(&dir) else { + return Ok(0); + }; + let prefix = cache_server_prefix(server); + let mut removed = 0usize; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name.starts_with(&prefix) && name.ends_with(".bin") { + fs::remove_file(&path)?; + removed += 1; + } + } + Ok(removed) } fn load_cache(path: &std::path::Path) -> Result { @@ -4665,18 +4767,19 @@ const TERMINAL_CLEANUP: &[u8] = concat!( mod tests { use super::{ DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor, - RemoteForward, SshConfig, StatusAction, auth_allows, ensure_tui_safe_status_overlay, - latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward, - parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table, - recv_response_until, render_status_clear, render_status_overlay, requested_env, - resolved_startup_command, rewrite_forward_command, ssh_destination_host, ssh_username, - ssh_with_user, startup_command, toml_bare_key_or_quoted, update_check_requested, - valid_forward_host, + RemoteForward, SshConfig, StatusAction, auth_allows, cache_key, cache_server_prefix, + clear_cached_credentials, ensure_tui_safe_status_overlay, latest_release_download_url, + load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward, + parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until, + render_status_clear, render_status_overlay, requested_env, resolved_startup_command, + rewrite_forward_command, ssh_destination_host, ssh_username, ssh_with_user, + startup_command, toml_bare_key_or_quoted, update_check_requested, valid_forward_host, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; use dosh::protocol::{self, Frame, PacketKind}; use ed25519_dalek::{SigningKey, VerifyingKey}; + use std::fs; use std::time::Duration; fn test_args(server: &str, command: &[&str]) -> super::Args { @@ -5452,6 +5555,30 @@ mod tests { assert!(valid_forward_host("server.example.com")); } + #[test] + fn recover_removes_only_matching_cached_credentials() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().display().to_string(); + let matching = dir + .path() + .join(format!("{}.bin", cache_key("user@host", "default", "rw"))); + let other = dir + .path() + .join(format!("{}.bin", cache_key("other", "default", "rw"))); + let similar = dir + .path() + .join(format!("{}.bin", cache_key("user@host2", "default", "rw"))); + fs::write(&matching, b"cached").unwrap(); + fs::write(&other, b"cached").unwrap(); + fs::write(&similar, b"cached").unwrap(); + + assert_eq!(cache_server_prefix("user@host"), "user_host_"); + assert_eq!(clear_cached_credentials(&root, "user@host").unwrap(), 1); + assert!(!matching.exists()); + assert!(other.exists()); + assert!(similar.exists()); + } + // --- Item 1: disconnect status line state machine --- /// The status line stays hidden while the link is fresh and only appears once diff --git a/src/lib.rs b/src/lib.rs index 51ab7aa..52cbf08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,9 @@ pub mod auth; pub mod config; pub mod crypto; pub mod native; +#[cfg(unix)] pub mod persist; pub mod protocol; +#[cfg(unix)] pub mod pty; pub mod ssh_agent; diff --git a/src/native.rs b/src/native.rs index 462924d..448ce33 100644 --- a/src/native.rs +++ b/src/native.rs @@ -11,6 +11,7 @@ use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as Signat use std::fs; use std::io::Write; use std::net::IpAddr; +#[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::str::FromStr; @@ -153,10 +154,11 @@ pub fn load_or_create_host_key(config: &ServerConfig) -> Result { fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let bytes = crypto::random_32(); - let mut file = fs::OpenOptions::new() - .create_new(true) - .write(true) - .mode(0o600) + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options .open(&path) .with_context(|| format!("create {}", path.display()))?; file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?; @@ -1233,11 +1235,11 @@ fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> { )); out.push('\n'); } - let mut file = fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .mode(0o600) + let mut options = fs::OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options .open(path) .with_context(|| format!("write {}", path.display()))?; file.write_all(out.as_bytes()) diff --git a/src/ssh_agent.rs b/src/ssh_agent.rs index 6e4ad32..06bebbe 100644 --- a/src/ssh_agent.rs +++ b/src/ssh_agent.rs @@ -1,18 +1,30 @@ +use crate::native::{ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth}; +#[cfg(unix)] use crate::native::{ - ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth, is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript, }; -use anyhow::{Context, Result, anyhow, bail}; +#[cfg(unix)] +use anyhow::{Context, bail}; +use anyhow::{Result, anyhow}; +#[cfg(unix)] use std::io::{Read, Write}; +#[cfg(unix)] use std::os::unix::net::UnixStream; use std::path::Path; +#[cfg(unix)] const SSH_AGENT_FAILURE: u8 = 5; +#[cfg(unix)] const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11; +#[cfg(unix)] const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12; +#[cfg(unix)] const SSH2_AGENTC_SIGN_REQUEST: u8 = 13; +#[cfg(unix)] const SSH2_AGENT_SIGN_RESPONSE: u8 = 14; +#[cfg(unix)] const SSH_AGENT_RSA_SHA2_512: u32 = 4; +#[cfg(unix)] const MAX_AGENT_PACKET: usize = 256 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] @@ -25,6 +37,7 @@ pub struct AgentIdentity { pub comment: String, } +#[cfg(unix)] pub fn sign_user_auth_with_agent( client: &NativeClientHello, server: &NativeServerHello, @@ -34,6 +47,18 @@ pub fn sign_user_auth_with_agent( sign_user_auth_with_agent_at(sock, client, server, requested_forwardings) } +#[cfg(not(unix))] +pub fn sign_user_auth_with_agent( + _client: &NativeClientHello, + _server: &NativeServerHello, + _requested_forwardings: Vec, +) -> Result { + Err(anyhow!( + "ssh-agent native auth is not supported on this platform yet; use identity_files" + )) +} + +#[cfg(unix)] pub fn sign_user_auth_with_agent_at( socket_path: impl AsRef, client: &NativeClientHello, @@ -58,6 +83,19 @@ pub fn sign_user_auth_with_agent_at( Ok(auth) } +#[cfg(not(unix))] +pub fn sign_user_auth_with_agent_at( + _socket_path: impl AsRef, + _client: &NativeClientHello, + _server: &NativeServerHello, + _requested_forwardings: Vec, +) -> Result { + Err(anyhow!( + "ssh-agent native auth is not supported on this platform yet; use identity_files" + )) +} + +#[cfg(unix)] fn request_supported_identities(agent: &mut UnixStream) -> Result> { write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?; let payload = read_agent_packet(agent)?; @@ -84,6 +122,7 @@ fn request_supported_identities(agent: &mut UnixStream) -> Result, comment: String) -> Result> { let algorithm = key_blob_algorithm(&key_blob)?; if !is_supported_user_key_algorithm(&algorithm) { @@ -119,6 +158,7 @@ fn supported_identity(key_blob: Vec, comment: String) -> Result Result> { let mut len = [0u8; 4]; stream @@ -177,6 +218,7 @@ fn read_agent_packet(stream: &mut UnixStream) -> Result> { Ok(payload) } +#[cfg(unix)] fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> { anyhow::ensure!( payload.len() <= MAX_AGENT_PACKET, @@ -187,6 +229,7 @@ fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> { Ok(()) } +#[cfg(unix)] fn read_u8(cursor: &mut &[u8]) -> Result { anyhow::ensure!(!cursor.is_empty(), "truncated u8"); let value = cursor[0]; @@ -194,6 +237,7 @@ fn read_u8(cursor: &mut &[u8]) -> Result { Ok(value) } +#[cfg(unix)] fn read_u32(cursor: &mut &[u8]) -> Result { anyhow::ensure!(cursor.len() >= 4, "truncated u32"); let value = u32::from_be_bytes(cursor[..4].try_into().unwrap()); @@ -201,6 +245,7 @@ fn read_u32(cursor: &mut &[u8]) -> Result { Ok(value) } +#[cfg(unix)] fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { let len = read_u32(cursor)? as usize; anyhow::ensure!(cursor.len() >= len, "truncated SSH string"); @@ -209,18 +254,20 @@ fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { Ok(value) } +#[cfg(unix)] 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); } +#[cfg(unix)] 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)] +#[cfg(all(test, unix))] mod tests { use super::*; use crate::native::{