Native v1: connection fix, hardening, forwarding robustness, prediction, benchmarks, docs
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

Integrates the stability fixes plus 5 parallel work tracks:
- Connection/panic/leak fixes + audit hardening (earlier on this branch)
- Track A: protocol VERSION 2 w/ clear mismatch reject, rekey, connection
  migration, native-auth rate limiting, O(1) conn_id index
- Track B: hostile-network integration tests + parser fuzzing + CI fuzz job
- Track C: benchmark path matrix + safe local harness + docs/BENCHMARKS.md
- Track D: docs/THREAT_MODEL.md + readiness/verification status
- Track E: clean-room mosh-grade predictive echo
- llms.txt agent overview

~112 tests green; cargo fmt clean. Wire VERSION is now 2: server and client
must be deployed together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DuProcess
2026-06-14 10:49:33 -04:00
31 changed files with 5004 additions and 396 deletions
+32
View File
@@ -19,6 +19,38 @@ jobs:
- name: Docker SSH benchmark gate
run: sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nightly toolchain
id: nightly
continue-on-error: true
uses: dtolnay/rust-toolchain@nightly
- name: Install cargo-fuzz
id: install
if: steps.nightly.outcome == 'success'
continue-on-error: true
run: cargo install cargo-fuzz --locked
- name: Run fuzz targets briefly
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: |
set -e
for target in \
packet_decode \
from_body \
authorized_keys \
known_hosts \
handshake_structs \
attach_ticket; do
echo "== fuzzing $target =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time=20 -rss_limit_mb=4096
done
- name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
remote-bench:
runs-on: ubuntu-latest
env:
+10 -8
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-docker-ssh bench-docker-mosh
.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh
build:
cargo build --release
@@ -12,14 +12,16 @@ fmt:
install:
sh packaging/install.sh
# Safe, self-contained local benchmark matrix (native cold auth, cached attach
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
# a temp HOME. Never touches the production server or UDP port 50000.
bench-local:
cargo build
tmp="$$(mktemp -d)"; \
HOME="$$tmp" target/debug/dosh-server serve >/tmp/dosh-bench-server.log 2>&1 & \
pid="$$!"; \
trap 'kill "$$pid" 2>/dev/null || true; rm -rf "$$tmp"' EXIT INT TERM; \
sleep 0.5; \
HOME="$$tmp" target/debug/dosh-bench --local-auth --server local --iterations 5
sh scripts/bench-local.sh
# Same matrix, machine-readable JSON output (one object per metric with raw
# samples). Useful for publishing or regression tracking.
bench-local-json:
DOSH_BENCH_JSON=1 sh scripts/bench-local.sh
bench-docker-ssh:
sh scripts/ci-docker-ssh-bench.sh
+30
View File
@@ -267,3 +267,33 @@ resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP atta
tickets, client ACKs, server retransmit bookkeeping, sliding replay protection,
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
script, Docker SSH benchmark gates, CI, and protocol/integration tests.
### Native v1
Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substantially
implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
servers:
- **Native UDP auth** with X25519 key exchange, transcript-bound Ed25519 user auth
via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and
`authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`;
unsupported options fail closed).
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so
bulk transfers do not lag the terminal.
- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks.
Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`):
the native authenticated path is tried first and falls back to SSH bootstrap
explicitly when native auth is disabled, unavailable, or rejected. It never silently
degrades to an unauthenticated mode.
Native v1 is **not yet fully verified**. Per-IP token-bucket rate limiting, protocol
VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external
security review are still open. See `docs/THREAT_MODEL.md` for the published threat
model and accepted residual risks, and the "Native v1 verification checklist status"
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
+7 -3
View File
@@ -4,7 +4,8 @@
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T`
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md`
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` (substantially implemented; see its v1 status block)
**Threat model:** `docs/THREAT_MODEL.md`
---
@@ -157,8 +158,11 @@ owner per session is preferred. Cross-thread designs are allowed only if benchma
## 7. Security Model
SSH is the first trust root. dosh does not implement a competing public-key login
system in v0.
SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies
on SSH for first authentication. Native v1 (`docs/NATIVE_V1_SPEC.md`) adds an opt-in
native public-key login over the Dosh UDP transport that is tried before SSH, with
its assets, attackers, and residual risks documented in `docs/THREAT_MODEL.md`. SSH
remains the explicit fallback and the host-key trust bootstrap path.
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
+173
View File
@@ -0,0 +1,173 @@
# Dosh Benchmarks
This document explains how to run the Dosh benchmarks, what each metric means,
and how to read the results honestly. Dosh's headline claim is *insane speed on
repeat attach/reconnect* — these benchmarks exist to make that claim measurable
and reproducible.
> **Read first:** `docs/PUBLIC_READINESS.md`. The defensible public claim is fast
> attach/reconnect, not full Mosh feature parity, and **not** cold startup. Cite
> `dosh_cached_attach_ms` for the core speed claim; cite cold metrics only to show
> the fallback path stays competitive with ordinary SSH.
## TL;DR
```bash
make bench-local # safe, self-contained matrix on a throwaway server
make bench-local-json # same, machine-readable JSON with raw samples
make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI
make bench-docker-mosh # same, with Mosh installed for a three-way comparison
```
`make bench-local` never touches a running production server: it builds release
binaries, starts its own `dosh-server` bound to `127.0.0.1` on a random free UDP
port inside a temporary `HOME`, runs the matrix, prints raw samples plus summary
statistics, and tears everything down on exit. It will refuse to bind UDP port
`50000` (the default production port).
## The benchmark binary: `dosh-bench`
`dosh-bench` spawns the real `dosh-client` once per iteration and times the whole
process from launch to terminal-ready-and-detached. That wall-clock cost is the
apples-to-apples comparison against `ssh host true`: it is the startup price each
tool pays before any useful remote work begins.
Every run prints, per metric, a summary line (count, min, median, p95, mean, max
in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable
object per run (with the full `samples_ms` array) so published numbers can always
include raw data, as required by `docs/PUBLIC_READINESS.md`.
### Path matrix
`dosh-bench` can benchmark these Dosh attach paths. Without an explicit path flag
it keeps its legacy single-path behavior (driven by `--local-auth` / `--warm-cache`
/ `--no-cache`) so existing CI scripts keep working.
| Flag | Metric | What it measures |
| --- | --- | --- |
| `--cold-native` | `dosh_cold_native_ms` | Native cold auth: full `--auth native --no-cache` handshake to first frame and detach. The cold fallback when no cache exists. |
| `--cached-ticket` | `dosh_cached_attach_ms` | Cached attach-ticket fast path. Warms the cache once, then measures repeat attaches using cached UDP credentials/tickets. **This is the core speed claim.** |
| `--resume` | `dosh_resume_ms` | UDP resume of a session whose endpoint changes. See the caveat below — only meaningful when a live session is kept open out-of-band. |
| `--local-auth` | `dosh_local_attach_ms` | Self-contained local bootstrap; no SSH, no native handshake. Useful as a lower-bound sanity check on loopback. |
| *(default, no flag)* | `dosh_attach_ms` | Cold SSH bootstrap plus UDP attach (legacy single-path mode). |
Existing baseline/comparison metrics are unchanged:
| Metric | Meaning |
| --- | --- |
| `ssh_true_ms` | `ssh host true` with the same key/options. |
| `mosh_start_true_ms` | `mosh host -- true` bootstrap, run `true`, exit (timed inside a PTY). |
### Assertions / gates
| Flag | Gate |
| --- | --- |
| `--assert-ssh-plus-ms N` | The primary Dosh metric must be ≤ `ssh_true_ms` mean + `N` ms. |
| `--assert-mosh-minus-ms N` | The primary Dosh metric must be at least `N` ms faster than `mosh_start_true_ms`. |
| `--assert-dosh-max-ms N` | The primary Dosh metric mean must be ≤ `N` ms. |
The "primary Dosh metric" for assertions is, in priority order:
`dosh_cached_attach_ms``dosh_resume_ms``dosh_cold_native_ms`
`dosh_attach_ms``dosh_local_attach_ms`.
## What each benchmark does
### `make bench-local` (`scripts/bench-local.sh`)
Self-contained, no SSH, no Docker. It:
1. Builds release binaries (`DOSH_BENCH_PROFILE=debug` for the debug profile).
2. Picks a free UDP port (never `50000`) and a temp `HOME`.
3. Generates a throwaway client identity (`ssh-keygen`), authorizes it on the
server, and writes a throwaway server + client config (native auth,
trust-on-first-use, localhost UDP).
4. Starts `dosh-server serve` bound to `127.0.0.1` on that port.
5. Runs `dosh-bench --cold-native --cached-ticket`, then `--local-auth --no-cache`.
6. Sanity-checks that native cold auth actually trusted the host (so a silent
config-parse fallback can't make the benchmark measure the wrong path).
7. Kills the server and removes the temp `HOME` on exit.
Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1`,
`DOSH_BENCH_PROFILE=release|debug`.
### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`)
Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster `dosh_attach_ms`,
`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same
container, key, and loopback network path. This is the gate CI enforces; it asserts
cold Dosh stays within 500 ms of SSH and that cached attach stays under a small
budget. See `README.md` "Develop" for the exact invocations.
## Methodology and caveats
- **Same machine, same network path.** `bench-local` runs everything on loopback,
so it isolates Dosh's own process/handshake/render overhead and removes network
RTT from the comparison. Real-world cached attach is approximately *network RTT
plus the local overhead these numbers measure*. Publish the machine, OS, CPU,
network path, and sample count alongside any numbers.
- **Wall-clock of the whole client process.** Each sample includes process spawn,
attach, first-frame render, and detach. That is deliberately the same thing
`ssh host true` pays, but it means a few ms is fixed process-startup cost, not
protocol cost.
- **Do not cite cold metrics as the headline.** `dosh_cold_native_ms` and
`dosh_attach_ms` still pay first-authentication cost. They exist to show the
fallback path stays competitive with ordinary SSH. Cite `dosh_cached_attach_ms`
for repeat attach/reconnect, per `docs/PUBLIC_READINESS.md`.
- **UDP resume is the roaming path, not cold reconnect.** Resume reconnects a
client that is *still attached* when its network endpoint changes (sleep/Wi-Fi
switch). The `--attach-only` benchmark model detaches after every iteration,
which removes the client on the server, so a fresh-process resume has nothing
live to resume and is not meaningful in `bench-local`. The cold fresh-process
reconnect fast path is the attach ticket (`dosh_cached_attach_ms`). Roaming
resume correctness is covered by the integration test
`resume_updates_udp_endpoint_for_roaming` in `tests/integration_smoke.rs`.
`dosh-bench --resume` remains available for scenarios that keep a live session
open out-of-band (for example remote soak tests).
- **These are not identical workloads.** SSH/Mosh/Dosh do different work at
startup. The comparison is still useful because it measures the startup tax each
tool charges before useful remote work begins.
## Sample results (loopback, self-contained)
Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in
milliseconds, 30 samples per metric.
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
- Toolchain: rustc 1.96.0, release profile
- Network path: loopback (`127.0.0.1`), throwaway server on a free UDP port
- Workload: `dosh-client --attach-only` to first frame and detach
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `dosh_cold_native_ms` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 |
| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 |
| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 |
Raw samples (ms):
```
dosh_cold_native_ms:
8.32, 8.23, 10.31, 10.37, 8.83, 8.81, 9.20, 9.04, 10.14, 8.98, 10.82, 9.87,
10.22, 10.42, 9.76, 9.09, 9.19, 8.94, 8.69, 8.67, 8.74, 9.33, 9.25, 8.92, 8.39,
8.55, 8.38, 9.81, 8.17, 8.10
dosh_cached_attach_ms:
2.99, 2.99, 2.93, 2.87, 2.95, 2.80, 2.80, 2.90, 3.10, 2.73, 3.19, 2.80, 2.87,
2.82, 2.89, 3.03, 3.06, 3.21, 2.97, 2.83, 3.00, 2.82, 3.04, 2.97, 3.03, 3.24,
3.30, 2.73, 2.79, 3.25
dosh_local_attach_ms:
3.05, 3.00, 2.84, 3.04, 3.33, 2.89, 2.76, 2.74, 2.73, 3.32, 3.14, 2.75, 2.83,
2.88, 2.99, 2.77, 2.75, 2.79, 2.69, 2.78, 2.75, 2.77, 2.75, 2.77, 3.03, 2.75,
2.94, 2.74, 2.82, 2.66
```
Reading these numbers: cold native auth is ~9 ms because it pays the native
handshake; cached attach is ~3 ms because it reuses cached credentials and skips
the handshake entirely. On loopback there is no network RTT, so this is the local
overhead floor. Over a real link, expect cached attach ≈ this floor + one network
round trip — which is exactly the "near network RTT" target in the spec. There is
no SSH/Mosh baseline in this loopback table because those paths need an SSH server;
use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head.
+25
View File
@@ -1,5 +1,30 @@
# Dosh Native v1 Spec
> **v1 status (annotation, not part of the spec text below).** Native v1 is
> substantially implemented and being stabilized; it is not yet fully verified.
> Milestone progress against section 15:
>
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
> `known_hosts`, and mismatch hard-fail are implemented.
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
> `AuthOk`, ssh-agent and encrypted-key Ed25519, and `authorized_keys` verification
> exist. ECDSA/RSA user keys are still pending (Ed25519 only today).
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are
> pending (Track C / `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and
> per-stream flow control are implemented; hostile-network and load tests pending.
> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting,
> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not
> yet complete. The threat model is published (`docs/THREAT_MODEL.md`).
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing.
>
> The section 16 verification checklist is **not yet fully green** — see the
> item-by-item status table in `docs/PUBLIC_READINESS.md` and the residual-risk list in
> `docs/THREAT_MODEL.md`. The section 17 public-claim gate is therefore **not met**.
Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
keeping SSH as a compatibility and recovery fallback.
+84 -19
View File
@@ -5,9 +5,17 @@ claim full Mosh replacement status until the feature matrix below is green and t
comparison benchmark is reproducible outside the author's homelab.
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`. Until that spec is
implemented and verified, Dosh's public security claim remains SSH-bootstrap plus
encrypted Dosh transport.
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
and `dosh doctor` all exist (see the feature matrix and the verification-checklist
status table below). It is **not yet fully verified**: per-IP token-bucket rate
limiting, protocol-version negotiation hardening, fuzzing in CI, and external review
are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is
green and that review is done, Dosh's defensible public security claim remains fast
encrypted native attach/reconnect with SSH-equivalent transport security and an
explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH
replacement.
## Objective Benchmarks
@@ -50,6 +58,10 @@ with ordinary SSH.
| Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519 via ssh-agent or encrypted key | implemented; pending full verification |
| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented |
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented |
| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests |
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
@@ -66,9 +78,11 @@ with ordinary SSH.
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel |
| Local TCP forwarding, `-L` | no | not implemented | planned |
| Remote TCP forwarding, `-R` | no | not implemented | planned |
| Dynamic SOCKS forwarding, `-D` | no | not implemented | later |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; needs hostile-network tests |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; needs hostile-network tests |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; needs load tests |
## SSH Config Inheritance
@@ -87,29 +101,77 @@ dosh import-ssh palav homelab
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Forwarding Plan
## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
because that would remove Dosh's fast reconnect advantage and would break after
roaming. Dosh forwarding needs native encrypted channels over the Dosh transport.
Minimum viable forwarding design:
roaming. Dosh forwarding therefore runs as native encrypted stream channels over the
Dosh transport, and is now implemented.
| CLI | Meaning |
| --- | --- |
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server; client connects to target. |
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server (loopback by default); client connects to target. |
| `dosh -D 1080 host` | SOCKS5 listener on the client; server connects to whatever the SOCKS request names. |
Protocol work required:
Implemented:
- Add stream-open, stream-data, stream-ack, and stream-close packet types.
- Use per-stream flow control separate from terminal frame ordering.
- Never let bulk forwarding traffic delay terminal input/output packets.
- Bind forwarding permissions to the same SSH-authenticated user as the terminal.
- Add tests with dropped/reordered UDP packets.
- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/
`StreamEof`/`StreamClose` packet types.
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
frames, so bulk forwarding does not block PTY input/output.
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
`--local-auth` and requires the native auth path.
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key
`no-port-forwarding` and `permitopen=`.
- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only
after listeners are ready).
This is a publishable differentiator once implemented, but it should not be claimed
until it exists.
Still open before claiming forwarding parity:
- A dedicated dropped/reordered/replayed-UDP forwarding test suite.
- Load tests proving large forwarded transfers add no visible terminal input lag.
## Native v1 Verification Checklist Status
This maps each item in `NATIVE_V1_SPEC.md` section 16 to its status based on what the
code in `src/` actually does. Done = implemented and exercised by tests or obvious
from code; in progress = partially implemented; pending = not yet implemented.
| Spec section 16 item | Status | Evidence / note |
| --- | --- | --- |
| Unknown host key fails unless TOFU explicitly enabled | done | Client refuses `KnownHostStatus::Unknown` unless `trust_on_first_use`. |
| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. |
| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. |
| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. |
| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. |
| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. |
| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. |
| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. |
| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. |
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
| Native cold auth beats cold `ssh host true` | pending | Benchmark gate not yet run for native cold path (Track C / `BENCHMARKS.md`). |
| Cached attach near network RTT | pending | Same benchmark dependency. |
| `-L` works without delaying terminal input | in progress | Implemented with per-stream window; load proof pending. |
| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. |
| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. |
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
| Multiple forwards in one command | done | Forward lists parsed and started together. |
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
| Large forwarded transfers add no visible input lag | in progress | Per-stream flow control exists; load test pending. |
| Fuzz targets run in CI | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. |
| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: in progress (another track). Today only
handshake-map eviction and a static `rate_limit_remaining` hint exist.
- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no
multi-version negotiation yet.
- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today.
## Before Public Launch
@@ -119,3 +181,6 @@ until it exists.
bracketed paste, and terminal cleanup.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI.
- Complete the native-v1 verification checklist above and an external security review
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17).
+232
View File
@@ -0,0 +1,232 @@
# Dosh Threat Model
This is the published threat model for Dosh native v1, derived from
`docs/NATIVE_V1_SPEC.md` sections 4-6. It states the assets Dosh protects, the
attackers it does and does not defend against, the security properties Dosh claims
relative to SSH, the cryptographic building blocks actually in use, and an honest
list of accepted residual risks and known gaps.
Dosh is a remote-login transport for Dosh-installed servers. It is intended to
replace the day-to-day `ssh host` workflow for terminals and TCP forwarding while
keeping OpenSSH as a recovery and bootstrap fallback. Dosh is **not** an
RFC-compatible SSH implementation and does not claim SSH's entire protocol security
surface. It claims security that is **equivalent to, and in some respects stronger
than, SSH for the Dosh terminal and forwarding use case** on hosts running
`dosh-server`.
## 1. Assets
Dosh protects:
- **Terminal session contents.** Keystrokes, command output, and the authoritative
screen state for every named or generated session.
- **Forwarded TCP streams.** Bytes carried over `-L`, `-R`, and `-D` channels.
- **User authentication credentials.** The user's SSH/Dosh private keys and any
ssh-agent identities. Dosh never sees private key material in plaintext on the
wire; signatures are produced locally or by the agent.
- **Server-issued credentials.** Session keys, `ClientId` association state,
server-sealed attach tickets, and the client-held attach-ticket PSK.
- **Server identity.** The persistent Dosh host key (`~/.config/dosh/host_key`) and
the server secret used to seal attach tickets and derive bootstrap material.
- **Host-trust state.** The client's pinned known-hosts file
(`~/.config/dosh/known_hosts`).
- **Authorization policy.** `~/.ssh/authorized_keys` /
`~/.config/dosh/authorized_keys` and the forwarding policy they encode.
## 2. Attackers
### In scope (Dosh must defend against these)
- **Passive network observer.** May record all UDP traffic between client and
server.
- **Active network attacker.** May spoof, drop, replay, reorder, or modify any
packet, and may attempt to inject forged packets in either direction.
- **NAT rebinding / roaming.** Client source IP and port may change mid-session,
including across sleep, network switch, and NAT timeout.
- **Stolen attach-ticket cache without the user's private key.** An attacker who
reads a client cache copies a server-sealed ticket plus its PSK but does not have
the user's SSH private key.
- **Server restart and key rotation.** Stale session keys, tickets, and replay
state must not be usable after the server rotates its secret or host key.
- **Malicious unauthenticated client flooding auth attempts.** A peer that has no
authorized key tries to exhaust server resources or guess credentials.
- **Compromised low-privilege local user on a shared client.** A different local
user attempts to read Dosh credential caches on the same machine.
### Out of scope (explicitly not defended against)
- **Compromised client machine.** If the endpoint running `dosh-client` is owned by
the attacker, the attacker has the user's keys and terminal.
- **Compromised server account.** If the login account on the server is owned, the
attacker already has the shell Dosh would have given them.
- **Malicious kernel, terminal emulator, or PTY implementation** on either side.
- **A server that was legitimately authorized and later turns malicious.** Host-key
pinning detects a *substituted* server, not a trusted server that decides to
misbehave.
These exclusions match SSH's own boundaries: SSH likewise cannot protect a
compromised endpoint or a malicious authorized peer.
## 3. Security Properties Claimed vs SSH
| Property | SSH | Dosh native v1 | Notes |
| --- | --- | --- | --- |
| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. |
| User authentication by private-key possession | yes | yes | Ed25519 via ssh-agent or encrypted OpenSSH key; signature binds the full transcript. |
| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. |
| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. |
| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. |
| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. |
| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/Ed25519 crates only. |
| Fail-closed downgrade behavior | yes | yes | Native auth failure surfaces an explicit error and SSH fallback is explicit; it never silently drops to an unauthenticated mode. |
| Fast resumption without re-auth | ControlMaster only | yes, native | Cached session/ticket attach skips a fresh round of public-key proof; this is a deliberate speed/security trade discussed in section 6. |
### Where Dosh aims to *exceed* SSH for this use case
- **Tighter transcript binding.** A user-auth signature binds both ephemeral keys,
both randoms, the server host key, requested user/session/mode/terminal size,
selected algorithms, and protocol version into one transcript. This forecloses
cross-protocol and partial-replay confusion classes for the narrow Dosh surface.
- **Smaller attack surface.** Dosh deliberately omits the full SSH transport/channel
machinery, arbitrary subsystems, X11, SFTP/SCP, and forced-command subsystems.
Fewer features means fewer parsers and fewer reachable states.
- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable
known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail
mismatch path, rather than the looser default TOFU behavior most SSH clients ship.
- **Modern primitives only.** Ed25519 and X25519 by default; no DSA, no SHA-1
signatures, no CBC-and-MAC constructions.
Dosh does **not** claim generic SSH compatibility and must not be described as an
SSH-protocol implementation.
## 4. Cryptographic Building Blocks (as implemented)
These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
`src/protocol.rs`, not just the spec.
- **Key exchange:** X25519 ephemeral-ephemeral (`x25519-dalek`). The shared secret
is checked for contributory behaviour; a non-contributory result is rejected.
- **Handshake/transport KDF:** HKDF-SHA256 (`hkdf`), salted with the SHA-256 of the
serialized `ClientHello` and `ServerHello`, binding traffic keys to the transcript.
- **AEAD:** ChaCha20-Poly1305 (`chacha20poly1305`) for every encrypted packet and
for sealed attach tickets. Nonces are derived as `direction || sequence`, giving a
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
not selectable today.
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
- **User-auth signatures:** Ed25519, produced either by ssh-agent over a Unix socket
(`src/ssh_agent.rs`) or from an encrypted/plaintext OpenSSH private key
(`ssh-key`). The signature covers the user-auth transcript described above.
- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
derived session keys, with attach tickets sealed under an HKDF-derived
ticket key. Token comparison is constant-time.
- **Hashing/transcript:** SHA-256 (`sha2`).
- **Randomness:** OS CSPRNG via `rand::thread_rng()` / `OsRng`.
No homegrown ciphers, MACs, padding schemes, or key derivation are used. No nonce or
key pair is reused within a direction.
## 5. How the Properties Are Enforced (handshake and transport)
- **Server authentication.** `ServerHello` carries the host public key and an Ed25519
signature over `dosh/native/server-hello/v1 || ClientHello || ServerHello(unsigned)`.
The client verifies this signature *and* checks the host key against its pinned
known-hosts entry before sending user auth or accepting terminal bytes. Unknown
keys are refused unless TOFU is enabled; mismatches hard-fail with both
fingerprints and the file path.
- **User authentication.** `UserAuth` carries an Ed25519 signature over
`dosh/native/user-auth/v1 || ClientHello || ServerHello || UserAuth(unsigned)`. The
server looks the public key up in `authorized_keys`, enforces authorized-key
options, then verifies the signature. A removed key can no longer authenticate.
- **Authorization options.** `from=` (with CIDR, glob, and negation),
`no-port-forwarding`, and `permitopen=` are enforced. `command=` is rejected for
native terminal login. Any unrecognized restrictive option fails closed rather than
being silently ignored.
- **Forwarding policy.** The server enforces `allow_tcp_forwarding`,
`allow_remote_forwarding`, and a loopback-only default for remote binds
(`allow_remote_non_loopback_bind`), in addition to per-key `permitopen=` /
`no-port-forwarding`.
- **Replay protection.** A 128-wide sliding window over the per-direction packet
counter rejects duplicates and stale sequences. Sequence 0 is never accepted.
- **Stale-key tolerance.** Each encrypted packet carries a `session_key_id`; packets
under an old key are dropped as "stale or wrong session key id" rather than treated
as fatal decrypt failures, so reconnect after rotation is non-destructive.
- **Roaming.** The server keys clients by `ClientId` and session key id, not by
source address, and updates the endpoint after any valid encrypted packet from a
new address — without weakening authentication, because the packet must still
decrypt and verify.
## 6. Accepted Residual Risks and Known Gaps
These are stated openly so the public claim gate (`NATIVE_V1_SPEC.md` section 17) can
be evaluated honestly. Items here are *not* yet "green".
### Accepted residual risks (by design)
- **Attach tickets prove recent server-issued possession, not fresh private-key
possession.** A stolen client cache containing both the sealed ticket and its PSK
can attach until the ticket expires (default TTL 24h, configurable down to zero) or
until the server rotates its secret/host key or the user key is removed. This is the
deliberate speed trade. It is bounded by TTL, scoped to host/user/session/mode, and
can be disabled. SSH ControlMaster has an analogous live-socket exposure.
- **Local cache confidentiality relies on filesystem permissions.** Host keys,
server secret, known-hosts, and credential caches are written `0600`. A
same-machine attacker who can already read another user's `0600` files (e.g. via
root) is out of scope, as with SSH's `~/.ssh`.
- **A trusted server that turns malicious is not detected.** Host-key pinning
detects substitution, not betrayal by an already-authorized server. This matches
SSH.
### Known gaps / work in progress (must close before the public claim)
- **Per-IP rate limiting is partial.** The server evicts half-finished native
handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map
without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`.
A full per-source token-bucket limiter is **in progress on another track** and is
not yet enforced. Until it lands, sustained auth flooding is mitigated only by the
handshake-eviction TTL and OS-level limits.
- **Protocol VERSION negotiation is being hardened.** The wire format pins a single
protocol version: the packet header rejects any non-matching `VERSION` byte and the
native handshake rejects any non-matching `protocol_version`. There is no
multi-version negotiation yet, so cross-version interop and downgrade-resistance for
future versions are still being designed. Today's behavior is fail-closed (reject),
not silent downgrade.
- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release
build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing,
authorized-key parsing, known-host parsing, and handshake state (spec milestone 5)
are **being wired in** and are not yet running in CI.
- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA
P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and
produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not
a weakening of what *is* supported.
- **Hostile-network and long-soak integration tests are partial.** Roaming,
retransmit, resize, and multi-client tests exist; a dedicated adversarial
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being
expanded.
- **No external security review yet.** The spec's milestone 5 requires an external
review checklist before public security claims. That review has not happened.
### Auth posture
Native auth is **opt-in alongside SSH fallback**. `auth_preference` defaults to
`native,ssh`: Dosh tries the native authenticated path first and falls back to SSH
bootstrap explicitly and visibly when native auth is disabled, unavailable, or
rejected. Native auth failure never silently degrades to an unauthenticated mode.
Forwarding (`-L`/`-R`/`-D`) requires the native authenticated path and refuses to run
under `--local-auth`.
## 7. Verification and Public-Claim Status
Dosh may claim "native SSH replacement for Dosh-installed servers" only after the
conditions in `NATIVE_V1_SPEC.md` section 17 are met: native auth default on a real
host, SSH fallback available, the section 16 checklist green, this threat model
published, and benchmarks with raw samples for SSH cold, Dosh native cold, Dosh
cached attach, and Mosh startup.
Current status: this threat model is published (this document). The verification
checklist is **not yet fully green** — see the item-by-item status table in
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
gaps in section 6 above. Until the gaps close and an external review is complete,
Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with
SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified,
externally reviewed SSH replacement.
+5
View File
@@ -0,0 +1,5 @@
/target
/corpus
/artifacts
/coverage
Cargo.lock
+64
View File
@@ -0,0 +1,64 @@
[package]
name = "dosh-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
# Standalone workspace so this crate is never absorbed by, and never affects,
# the main `dosh` crate's `cargo build` / `cargo test` / `cargo fmt --check`.
[workspace]
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.dosh]
path = ".."
# cargo-fuzz needs unwinding to report panics; keep debug assertions on.
[profile.release]
debug = 1
[[bin]]
name = "packet_decode"
path = "fuzz_targets/packet_decode.rs"
test = false
doc = false
bench = false
[[bin]]
name = "from_body"
path = "fuzz_targets/from_body.rs"
test = false
doc = false
bench = false
[[bin]]
name = "authorized_keys"
path = "fuzz_targets/authorized_keys.rs"
test = false
doc = false
bench = false
[[bin]]
name = "known_hosts"
path = "fuzz_targets/known_hosts.rs"
test = false
doc = false
bench = false
[[bin]]
name = "handshake_structs"
path = "fuzz_targets/handshake_structs.rs"
test = false
doc = false
bench = false
[[bin]]
name = "attach_ticket"
path = "fuzz_targets/attach_ticket.rs"
test = false
doc = false
bench = false
+59
View File
@@ -0,0 +1,59 @@
# dosh fuzz targets
cargo-fuzz / libFuzzer harnesses for the `dosh` parsers and handshake verifiers
(spec milestone 5 / §16: "Fuzz packet parsing, authorized-key parsing,
known-host parsing, and handshake state", "Fuzz targets run in CI").
This is a **standalone crate** (its own `[workspace]`) so it never affects the
main crate's `cargo build` / `cargo test` / `cargo fmt --check`.
## Targets
| Target | Parser(s) exercised |
| --- | --- |
| `packet_decode` | `protocol::Header::parse`, `protocol::decode`, `protocol::decrypt_body` |
| `from_body` | `protocol::from_body::<T>` for every protocol & native wire struct |
| `authorized_keys` | `native::parse_authorized_keys`, `native::parse_ssh_ed25519_public_blob` |
| `known_hosts` | `native::parse_known_hosts`, `native::parse_host_public_key_line` |
| `handshake_structs` | handshake struct decode + `verify_server_hello`, `user_auth_transcript`, `verify_native_user_auth` |
| `attach_ticket` | `auth::open_attach_ticket`, `auth::verify_attach_ticket`, `auth::decode_bootstrap` |
Every target's objective is the same: **no panics on any input** (a panic on
untrusted bytes is a robustness/DoS bug per threat model §5).
## Prerequisites
```sh
rustup toolchain install nightly
cargo install cargo-fuzz
```
cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
## Run
From the repository root:
```sh
# List targets
cargo +nightly fuzz list --fuzz-dir fuzz
# Run a single target indefinitely
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode
# Short, CI-style smoke run of one target (10 seconds)
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode -- -max_total_time=10
```
Or from inside `fuzz/`:
```sh
cd fuzz
cargo +nightly fuzz run packet_decode -- -max_total_time=10
```
## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
cargo-fuzz and runs each target briefly (`-max_total_time`). The job is tolerant
if the toolchain/tooling is unavailable so it never blocks the main test gate.
+20
View File
@@ -0,0 +1,20 @@
#![no_main]
//! Fuzz the attach-ticket and bootstrap decoders. These open server-sealed
//! AEAD blobs and base64 bootstrap envelopes from cache / wire material that an
//! attacker may corrupt; none may panic.
use libfuzzer_sys::fuzz_target;
use dosh::auth::{decode_bootstrap, open_attach_ticket, verify_attach_ticket};
fuzz_target!(|data: &[u8]| {
let secret = [0x11u8; 32];
let psk = [0x22u8; 32];
let _ = open_attach_ticket(&secret, data);
let _ = verify_attach_ticket(&secret, data, &psk, "default", "read-write");
if let Ok(text) = std::str::from_utf8(data) {
let _ = decode_bootstrap(text);
}
});
+18
View File
@@ -0,0 +1,18 @@
#![no_main]
//! Fuzz the authorized_keys parser, including its option lexer and the
//! ssh-ed25519 public-key blob parser it depends on. None may panic.
use libfuzzer_sys::fuzz_target;
use dosh::native::{parse_authorized_keys, parse_ssh_ed25519_public_blob};
fuzz_target!(|data: &[u8]| {
// The blob parser operates directly on raw bytes.
let _ = parse_ssh_ed25519_public_blob(data);
// The line parser operates on text; only feed valid UTF-8 (lossless),
// matching how the file is read in production via read_to_string.
if let Ok(text) = std::str::from_utf8(data) {
let _ = parse_authorized_keys(text);
}
});
+62
View File
@@ -0,0 +1,62 @@
#![no_main]
//! Fuzz `protocol::from_body` (bincode deserialization) for every protocol and
//! native struct that is decoded from untrusted wire bytes. None may panic.
use libfuzzer_sys::fuzz_target;
use dosh::auth::{AttachTicketPlain, BootstrapResponse, SealedAttachTicket};
use dosh::native::{
HostPublicKey, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Input, NativeAuthCheckOkBody,
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, Resize,
ResumeRequest, StreamClose, StreamData, StreamEof, StreamOpen, StreamOpenOk, StreamOpenReject,
StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
};
macro_rules! try_body {
($data:expr, $ty:ty) => {
let _ = protocol::from_body::<$ty>($data);
};
}
fuzz_target!(|data: &[u8]| {
// protocol.rs structs
try_body!(data, BootstrapAttachRequest);
try_body!(data, TicketAttachEnvelope);
try_body!(data, TicketAttachBody);
try_body!(data, TicketAttachOkEnvelope);
try_body!(data, AttachOk);
try_body!(data, AttachReject);
try_body!(data, ResumeRequest);
try_body!(data, Input);
try_body!(data, Resize);
try_body!(data, Frame);
try_body!(data, StreamOpen);
try_body!(data, StreamOpenOk);
try_body!(data, StreamOpenReject);
try_body!(data, StreamData);
try_body!(data, StreamWindowAdjust);
try_body!(data, StreamEof);
try_body!(data, StreamClose);
// native handshake wrapper bodies
try_body!(data, NativeClientHelloBody);
try_body!(data, NativeServerHelloBody);
try_body!(data, NativeUserAuthBody);
try_body!(data, NativeAuthOkBody);
try_body!(data, NativeAuthCheckOkBody);
// bare native handshake structs
try_body!(data, NativeClientHello);
try_body!(data, NativeServerHello);
try_body!(data, NativeUserAuth);
try_body!(data, NativeAuthOk);
try_body!(data, HostPublicKey);
// auth.rs structs
try_body!(data, BootstrapResponse);
try_body!(data, SealedAttachTicket);
try_body!(data, AttachTicketPlain);
});
+44
View File
@@ -0,0 +1,44 @@
#![no_main]
//! Fuzz the native handshake structs and their structural verifiers.
//!
//! Beyond plain deserialization (covered by the `from_body` target), this drives
//! the verifier state machine: if the input happens to decode into the handshake
//! structs, run `verify_server_hello`, `user_auth_transcript`, and
//! `verify_native_user_auth` on them. These run on attacker-controlled material
//! during the handshake and must reject (Err) without panicking.
use libfuzzer_sys::fuzz_target;
use dosh::native::{
NativeClientHello, NativeServerHello, NativeUserAuth, user_auth_transcript,
verify_native_user_auth, verify_server_hello,
};
use dosh::protocol;
fuzz_target!(|data: &[u8]| {
// Split the input into three slices and try to decode each into a handshake
// struct. Use a length prefix scheme that is robust to short inputs.
if data.len() < 3 {
return;
}
let n = data.len();
let a = n / 3;
let b = 2 * n / 3;
let (chunk_client, chunk_server, chunk_auth) = (&data[..a], &data[a..b], &data[b..]);
let client: Option<NativeClientHello> = protocol::from_body(chunk_client).ok();
let server: Option<NativeServerHello> = protocol::from_body(chunk_server).ok();
let auth: Option<NativeUserAuth> = protocol::from_body(chunk_auth).ok();
if let (Some(client), Some(server)) = (&client, &server) {
// Host signature verification over the transcript must not panic.
let _ = verify_server_hello(client, server);
if let Some(auth) = &auth {
// Transcript construction and full user-auth verification (signature
// check + authorized-key matching) must not panic on garbage.
let _ = user_auth_transcript(client, server, auth);
let _ = verify_native_user_auth(client, server, auth, &[], None);
}
}
});
+14
View File
@@ -0,0 +1,14 @@
#![no_main]
//! Fuzz the known_hosts parser and the host-public-key line parser. None may
//! panic on arbitrary input.
use libfuzzer_sys::fuzz_target;
use dosh::native::{parse_host_public_key_line, parse_known_hosts};
fuzz_target!(|data: &[u8]| {
if let Ok(text) = std::str::from_utf8(data) {
let _ = parse_known_hosts(text);
let _ = parse_host_public_key_line(text);
}
});
+23
View File
@@ -0,0 +1,23 @@
#![no_main]
//! Fuzz the wire-packet decoder + decrypt path.
//!
//! Mirrors tests/parser_robustness.rs but driven by libFuzzer so the coverage
//! engine can search for panics in `protocol::decode`, `Header::parse`, and the
//! decode -> decrypt_body pipeline. The objective is: NO PANICS on any input.
use libfuzzer_sys::fuzz_target;
use dosh::protocol::{self, CLIENT_TO_SERVER, SERVER_TO_CLIENT};
fuzz_target!(|data: &[u8]| {
// Header parsing must never panic.
let _ = protocol::Header::parse(data);
// Full decode, then attempt decryption with a fixed key in both directions.
// A real attacker controls these bytes; neither path may panic.
if let Ok(packet) = protocol::decode(data) {
let key = [0x42u8; 32];
let _ = protocol::decrypt_body(&packet, &key, CLIENT_TO_SERVER);
let _ = protocol::decrypt_body(&packet, &key, SERVER_TO_CLIENT);
}
});
+201
View File
@@ -0,0 +1,201 @@
# dosh (Dormant Shell)
> dosh is a low-latency remote terminal for homelab/personal servers. It is mosh-shaped
> but not a mosh clone: `dosh-server` is a resident daemon that keeps terminal sessions
> hot, and the client reconnects over encrypted UDP — so attach and reconnect are
> near-instant (~3 ms of local overhead + one network RTT). SSH is used once to
> establish trust; after that, repeat attaches skip SSH entirely. It also does SSH-style
> TCP port forwarding (`-L`/`-R`/`-D`) over the same encrypted transport, which is what
> makes back-and-forth client↔server homelab comms easy.
This file orients an AI agent (or a human) on what dosh is, what it can do, and how to
drive it. It is intentionally self-contained. For deeper detail, see the linked docs at
the bottom.
## What dosh is for
Use dosh instead of `ssh`/`mosh` for **interactive shells and TCP forwarding** to a
server where you control both ends and have installed `dosh-server` (typically a homelab
box, VPS, or workstation). It shines when you:
- want a terminal that survives laptop sleep, Wi-Fi changes, and NAT rebinding, and
resumes instantly instead of hanging;
- reconnect to the same box many times a day and don't want to pay SSH startup each time;
- need to reach services on the server from your laptop (or vice-versa) without standing
up a VPN.
dosh is **not** a drop-in for every SSH use. It does not do `scp`/`sftp` file transfer,
X11, or act as an `sshd` for arbitrary SSH clients. Keep `ssh` installed for those.
## Core capabilities
- **Encrypted UDP terminal transport** — AEAD-encrypted, with packet sequencing, a
sliding replay window, ACKs, and server-side retransmit of unacked output.
- **Resident server + hot sessions** — `dosh-server` runs as a daemon; named sessions
(and a prewarmed `default`) stay alive across client disconnects.
- **Fast attach / reconnect** — see "Fast path order" below; cached attach is ~one RTT.
- **Roaming** — the session follows the client across IP/port changes.
- **Named & shared sessions** — reattach the same persistent terminal from multiple
clients; optional **view-only** clients.
- **TCP port forwarding** — local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) over
the encrypted transport, with per-stream flow control and terminal-priority
scheduling (bulk transfers don't lag your keystrokes).
- **Native UDP auth** — Ed25519 user auth via ssh-agent or an (optionally encrypted)
OpenSSH key, verified against `authorized_keys`. Falls back to SSH bootstrap when
native auth isn't available. SSH host config (`HostName`, `User`, `Port`,
`IdentityFile`, `ProxyJump`, etc.) is honored.
- **Host-key pinning** — TOFU/known-hosts with hard-fail on mismatch.
- **Speculative local echo** — optional predictive echo for laggy links (display-only;
real input is always sent to the server).
- **Ops commands** — `doctor`, `sessions`, `trust`, `import-ssh`, `update`.
## Quickstart
Install the server on each box you want to reach (default UDP port `50000`):
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_PORT=50000 sh -s -- server
```
Install the client (macOS/Linux), then attach:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_SERVER=homelab DOSH_HOST=homelab.example.com DOSH_PORT=50000 sh -s -- client
dosh homelab # fresh interactive shell
dosh homelab uptime # run one command
dosh --session work homelab # named, persistent, reattachable session
```
- Detach (leave the server session running): **Ctrl-]**.
- End the session: type `exit` in the remote shell.
- If UDP stalls, dosh keeps the terminal open, sends keepalives, and ticket-reconnects.
## Client↔server homelab comms (the back-and-forth)
Forwarding is the key to "make homelab comms easy." Three directions:
| Command | Direction | Effect |
| --- | --- | --- |
| `dosh -L [bind:]LPORT:THOST:TPORT host` | pull server→you | A listener on **your machine** (`bind`, default localhost) forwards to `THOST:TPORT` reached **from the server**. |
| `dosh -R [bind:]LPORT:THOST:TPORT host` | push you→server | A listener on **the server** (loopback by default) forwards to `THOST:TPORT` reached **from your machine**. |
| `dosh -D [bind:]LPORT host` | SOCKS via server | A SOCKS5 proxy on **your machine**; traffic egresses **from the server**. |
Concrete homelab patterns:
```bash
# Reach the homelab's internal Grafana (server-side :3000) from your laptop browser:
dosh -L 3000:127.0.0.1:3000 homelab # open http://localhost:3000
# Reach a DB that only listens on the homelab LAN:
dosh -L 5432:10.0.0.5:5432 homelab # psql -h 127.0.0.1 -p 5432
# Let the homelab hit a dev server running on your laptop (e.g. a webhook target):
dosh -R 9000:127.0.0.1:8080 homelab # homelab curls http://127.0.0.1:9000
# Route browser traffic out through the homelab's network:
dosh -D 1080 homelab # SOCKS5 proxy at 127.0.0.1:1080
# Forward-only, no shell; multiple forwards; background after listeners are up:
dosh -N -L 3000:127.0.0.1:3000 -L 5432:10.0.0.5:5432 homelab
dosh -f -N -L 3000:127.0.0.1:3000 homelab
```
Server policy controls forwarding: `allow_tcp_forwarding`, `allow_remote_forwarding`,
`allow_remote_non_loopback_bind`, and per-key `permitopen=`/`no-port-forwarding` in
`authorized_keys`. Remote listeners bind to loopback unless explicitly allowed.
## Fast path order (why it's quick)
The client tries the cheapest valid path first:
1. **UDP resume** — existing client id + session key; one encrypted UDP round-trip.
2. **UDP attach ticket** — cached server-issued ticket; one round-trip, no SSH.
3. **Native UDP auth** — Ed25519 handshake (ssh-agent/key) when enabled.
4. **SSH bootstrap** — `ssh user@host dosh-auth …` once, then a UDP attach.
Measured locally (loopback, release): cached attach ≈ **3 ms**, cold native auth ≈ 9 ms.
Over a real link, add one RTT. See `docs/BENCHMARKS.md`.
## Architecture (1-minute model)
- **dosh-server** — single UDP socket on one port; a session table keyed by name; one
PTY per named session; per-session terminal screen state (vt100) for snapshots/diffs;
a per-session client table; encrypted UDP protocol; a small SSH-invoked `dosh-auth`
helper. Abandoned (clientless, non-prewarmed) sessions and their shells are reaped
after a grace period; prewarmed sessions stay hot.
- **dosh-client** — raw-mode terminal; local credential cache; tries resume/ticket/native
before SSH; forwards PTY I/O; reconnect/roaming state machine; optional predictive echo.
- **Binaries** — `dosh-server`, `dosh-client` (symlinked as `dosh`), `dosh-auth`
(SSH-invoked trust helper), `dosh-bench` (benchmarks).
## Security model (summary)
- First trust via SSH; thereafter encrypted Dosh transport. Native auth available.
- KEX X25519; AEAD ChaCha20-Poly1305; KDF HKDF-SHA256; host & user auth Ed25519;
SHA-256 transcript binding. Per-direction, per-sequence nonces; replay window.
- Host keys pinned in `~/.config/dosh/known_hosts`; mismatch hard-fails.
- User auth against `~/.ssh/authorized_keys` (+ optional `~/.config/dosh/authorized_keys`);
removed keys can't authenticate; unsupported restrictive options fail closed.
- Forward secrecy from ephemeral X25519; attach tickets are server-sealed and scoped.
dosh claims security **equivalent to, and in places stronger than, SSH for the dosh
terminal/forwarding use case** — not full SSH-protocol parity. Read `docs/THREAT_MODEL.md`
for the honest stance, residual risks, and what's still pending before public claims.
## Configuration
- **Client** `~/.config/dosh/client.toml` — `auth_preference` (e.g. `"native,ssh"`),
`trust_on_first_use`, `identity_files`, `use_ssh_agent`, `forward_agent`, `send_env`,
`set_env`, `dosh_port`, `default_session`, `predict`, `reconnect_timeout_secs`,
`credential_cache`, `known_hosts`.
- **Hosts** `~/.config/dosh/hosts.toml` — per-alias `[name]` with `ssh`, `dosh_host`,
`port`, `user`, `default_command`, `predict`. Generate from SSH aliases with
`dosh import-ssh <alias> <name>`.
- **Server** `~/.config/dosh/server.toml` — `port`, `bind`, `shell`, `prewarm_sessions`,
`native_auth`, `host_key`, `authorized_keys`, `attach_ticket_ttl_secs`,
`client_timeout_secs`, `allow_tcp_forwarding`, `allow_remote_forwarding`,
`allow_remote_non_loopback_bind`, `allow_agent_forwarding`, `accept_env`,
`native_auth_rate_limit_per_minute`.
Paths: host key `~/.config/dosh/host_key`; credential cache
`~/.local/share/dosh/credentials/`.
## Operating & diagnostics
```bash
dosh doctor homelab # host resolution, trust state, UDP reachability, server
# version, usable keys, auth result, forwarding policy
dosh sessions homelab # list live sessions
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
dosh trust --remove homelab
dosh import-ssh palav homelab # write a hosts.toml entry from an SSH alias
dosh update # update the installed client
```
If something fails, `dosh doctor <host>` 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 <host> <cmd...>`. Render one frame and detach:
`dosh --attach-only <host>`. 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 <name>` to reattach a known session; bare `dosh <host>` 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 <host>`.
## 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.
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env sh
# Safe, self-contained local Dosh benchmark harness.
#
# Spins up a THROWAWAY dosh-server bound to 127.0.0.1 on a random free port in a
# temp HOME, runs the full path matrix (native cold auth, cached attach-ticket,
# UDP resume, and local-auth), prints raw samples + summary stats, then tears
# everything down.
#
# It NEVER touches the production server: it never uses UDP port 50000, never
# restarts any systemd unit, and never reads or writes the real ~/.config/dosh
# or ~/.local. Everything lives under a mktemp HOME that is removed on exit.
#
# Usage:
# scripts/bench-local.sh [ITERATIONS]
# Environment:
# DOSH_BENCH_ITERS iteration count (default 20; overridden by $1)
# DOSH_BENCH_JSON=1 emit machine-readable JSON instead of the table
# DOSH_BENCH_PROFILE release|debug build profile (default release)
set -eu
iters="${1:-${DOSH_BENCH_ITERS:-20}}"
profile="${DOSH_BENCH_PROFILE:-release}"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
# Make sure cargo is reachable in non-login shells.
if ! command -v cargo >/dev/null 2>&1; then
# shellcheck disable=SC1090
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
if [ "$profile" = "release" ]; then
cargo build --release >&2
bindir="$repo_root/target/release"
else
cargo build >&2
bindir="$repo_root/target/debug"
fi
server_bin="$bindir/dosh-server"
client_bin="$bindir/dosh-client"
bench_bin="$bindir/dosh-bench"
# Pick a free UDP port that is NOT the production port (50000).
free_udp_port() {
python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
}
dosh_port="$(free_udp_port)"
if [ "$dosh_port" = "50000" ]; then
dosh_port="$(free_udp_port)"
fi
if [ "$dosh_port" = "50000" ]; then
echo "refusing to bind production port 50000" >&2
exit 1
fi
home="$(mktemp -d)"
server_pid=""
cleanup() {
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
rm -rf "$home"
}
trap cleanup EXIT INT TERM
mkdir -p "$home/.config/dosh" "$home/.ssh" "$home/.local/share/dosh"
# Client identity used by the native cold-auth path.
ssh-keygen -t ed25519 -N "" -q -f "$home/.ssh/id_ed25519"
client_pub="$(cat "$home/.ssh/id_ed25519.pub")"
printf '%s\n' "$client_pub" > "$home/authorized_keys"
# Throwaway server config: localhost only, throwaway port, attach tickets on.
cat > "$home/.config/dosh/server.toml" <<EOF
port = $dosh_port
bind = "127.0.0.1"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 60
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "/bin/sh"
sessions_dir = "$home/sessions"
secret_path = "$home/secret"
host_key = "$home/host_key"
authorized_keys = ["$home/authorized_keys"]
EOF
# Client config: native auth, trust-on-first-use (no SSH needed), localhost UDP.
write_client_config() {
# $1 = cache_attach_tickets (true|false)
cat > "$home/.config/dosh/client.toml" <<EOF
server = "local"
dosh_host = "127.0.0.1"
dosh_port = $dosh_port
default_session = "default"
reconnect_timeout_secs = 5
view_only = false
predict = false
cache_attach_tickets = $1
credential_cache = "$home/.local/share/dosh/credentials"
known_hosts = "$home/.config/dosh/known_hosts"
auth_preference = "native"
trust_on_first_use = true
EOF
}
start_server() {
HOME="$home" "$server_bin" serve --config "$home/.config/dosh/server.toml" \
>"$home/server.log" 2>&1 &
server_pid="$!"
# Wait for the UDP socket to come up.
i=0
while [ "$i" -lt 50 ]; do
if grep -q "listening on" "$home/server.log" 2>/dev/null; then
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
echo "dosh-server exited early:" >&2
cat "$home/server.log" >&2 || true
exit 1
fi
i=$((i + 1))
sleep 0.1
done
}
run_bench() {
# $@ extra args forwarded to dosh-bench
json_flag=""
[ "${DOSH_BENCH_JSON:-0}" = "1" ] && json_flag="--json"
label="$(uname -s) $(uname -m), profile=$profile"
HOME="$home" "$bench_bin" \
--client "$client_bin" \
--server local \
--dosh-host 127.0.0.1 \
--dosh-port "$dosh_port" \
--session default \
--skip-ssh-baseline \
--iterations "$iters" \
--label "$label" \
$json_flag \
"$@"
}
echo "dosh local benchmark: port=$dosh_port iters=$iters profile=$profile home=$home" >&2
# 1) Native cold auth + cached attach-ticket in one run (ticket cache on).
write_client_config true
start_server
echo "== native cold auth + cached attach-ticket ==" >&2
run_bench --cold-native --cached-ticket
# Sanity: native cold auth must have trusted the host (proves the generated
# client config loaded and the native handshake ran instead of silently
# falling back to a different path).
if [ ! -s "$home/.config/dosh/known_hosts" ]; then
echo "native cold auth did not record a trusted host; check server.log:" >&2
cat "$home/server.log" >&2 || true
exit 1
fi
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
server_pid=""
# 2) Self-contained local-auth path (no SSH, no native handshake).
#
# Note on UDP resume: resume is the roaming path for a client that is STILL
# attached when its network endpoint changes. The `--attach-only` benchmark
# model detaches after each iteration, which tears the client down on the
# server, so a fresh-process resume has nothing live to resume and is not
# meaningful here. The cold fresh-process reconnect fast path is the attach
# ticket (measured above). Roaming resume is validated by the integration test
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
# scenarios that keep a live session out-of-band (e.g. remote soak tests); see
# docs/BENCHMARKS.md.
rm -rf "$home/.local/share/dosh/credentials"
write_client_config true
start_server
echo "== local-auth (no SSH) ==" >&2
run_bench --local-auth --no-cache
+6
View File
@@ -68,6 +68,12 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
.decode(String::from_utf8_lossy(&raw).trim())
.context("decode server secret")?
};
anyhow::ensure!(
decoded.len() == 32,
"server secret at {} must be 32 bytes, got {}",
path.display(),
decoded.len()
);
let mut out = [0u8; 32];
out.copy_from_slice(&decoded[..32]);
return Ok(out);
+376 -91
View File
@@ -25,6 +25,15 @@ struct Args {
iterations: usize,
#[arg(long)]
local_auth: bool,
/// Benchmark native cold auth (no cache, native handshake) terminal-ready time.
#[arg(long)]
cold_native: bool,
/// Benchmark cached attach-ticket terminal-ready time (warms the cache first).
#[arg(long)]
cached_ticket: bool,
/// Benchmark UDP resume terminal-ready time (warms the cache first).
#[arg(long)]
resume: bool,
#[arg(long)]
client: Option<PathBuf>,
#[arg(long, default_value = "~/.local/bin/dosh-auth")]
@@ -51,6 +60,12 @@ struct Args {
mosh_server_command: String,
#[arg(long)]
mosh_port: Option<String>,
/// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)]
json: bool,
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)]
label: Option<String>,
#[arg(long)]
assert_ssh_plus_ms: Option<f64>,
#[arg(long)]
@@ -59,12 +74,45 @@ struct Args {
assert_dosh_max_ms: Option<f64>,
}
/// One Dosh attach path to benchmark.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoshPath {
/// `--auth native --no-cache`: full native handshake, no cache.
ColdNative,
/// Cached attach-ticket fast path (requires a warmed cache).
CachedTicket,
/// UDP resume fast path (requires a warmed cache + `cache_attach_tickets = false`).
Resume,
/// `--local-auth`: self-contained local bootstrap, no SSH.
LocalAuth,
/// SSH-bootstrap cold attach (cache controlled by `--no-cache` / `--warm-cache`).
SshBootstrap,
}
impl DoshPath {
fn metric(self) -> &'static str {
match self {
DoshPath::ColdNative => "dosh_cold_native_ms",
DoshPath::CachedTicket => "dosh_cached_attach_ms",
DoshPath::Resume => "dosh_resume_ms",
DoshPath::LocalAuth => "dosh_local_attach_ms",
DoshPath::SshBootstrap => "dosh_attach_ms",
}
}
/// Whether this path consumes a warmed credential cache.
fn needs_warm(self) -> bool {
matches!(self, DoshPath::CachedTicket | DoshPath::Resume)
}
}
fn main() -> Result<()> {
let args = Args::parse();
let client = args.client.clone().unwrap_or_else(default_client_path);
let mut ssh_times = Vec::new();
let mut dosh_times = Vec::new();
let mut mosh_times = Vec::new();
if args.no_cache && args.warm_cache {
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
}
let generated_control_path = if args.controlmaster {
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
} else {
@@ -78,96 +126,112 @@ fn main() -> Result<()> {
} else {
None
};
if args.no_cache && args.warm_cache {
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
}
let dosh_label = if args.warm_cache {
let _ = time_dosh_attach(&client, &args, control_path)?;
"dosh_cached_attach_ms"
// Resolve which Dosh paths to benchmark. Explicit path flags select an
// explicit matrix; otherwise fall back to the legacy single-path behavior
// so existing callers (CI docker scripts) keep working unchanged.
let explicit_paths = explicit_dosh_paths(&args);
let dosh_paths = if explicit_paths.is_empty() {
vec![legacy_dosh_path(&args)]
} else {
"dosh_attach_ms"
explicit_paths
};
for _ in 0..args.iterations.max(1) {
if !args.local_auth && !args.skip_ssh_baseline {
let mut results: Vec<MetricSamples> = Vec::new();
// SSH baseline (shared across the matrix; the comparison is per-iteration
// ssh-true vs the dosh path startup that replaces it).
let run_ssh = !args.local_auth && !args.skip_ssh_baseline && !dosh_only(&dosh_paths);
if run_ssh {
let mut ssh_times = Vec::new();
for _ in 0..args.iterations.max(1) {
let mut ssh = Command::new("ssh");
add_ssh_options(&mut ssh, &args, control_path);
ssh.arg(&args.server).arg("true");
ssh_times.push(time_command(&mut ssh)?);
}
results.push(MetricSamples::new("ssh_true_ms", ssh_times));
}
dosh_times.push(time_dosh_attach(&client, &args, control_path)?);
for path in &dosh_paths {
if path.needs_warm() {
// Prime the cache with one attach so the fast path has credentials.
let _ = time_dosh_attach(&client, &args, *path, control_path, true)?;
}
let mut samples = Vec::new();
for _ in 0..args.iterations.max(1) {
samples.push(time_dosh_attach(
&client,
&args,
*path,
control_path,
false,
)?);
}
results.push(MetricSamples::new(path.metric(), samples));
}
if args.include_mosh {
if args.include_mosh {
let mut mosh_times = Vec::new();
for _ in 0..args.iterations.max(1) {
mosh_times.push(time_mosh_in_pty(&args)?);
}
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
}
if !ssh_times.is_empty() {
println!(
"ssh_true_ms avg={:.2} samples={:?}",
avg_ms(&ssh_times),
ssh_times
);
}
println!(
"{dosh_label} avg={:.2} samples={:?}",
avg_ms(&dosh_times),
dosh_times
);
if !mosh_times.is_empty() {
println!(
"mosh_start_true_ms avg={:.2} samples={:?}",
avg_ms(&mosh_times),
mosh_times
);
}
if let Some(margin) = args.assert_ssh_plus_ms {
if ssh_times.is_empty() {
return Err(anyhow!(
"--assert-ssh-plus-ms requires non-local SSH benchmark"
));
}
let ssh_avg = avg_ms(&ssh_times);
let dosh_avg = avg_ms(&dosh_times);
if dosh_avg > ssh_avg + margin {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms exceeded ssh avg {ssh_avg:.2}ms + {margin:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms");
}
if let Some(margin) = args.assert_mosh_minus_ms {
if mosh_times.is_empty() {
return Err(anyhow!(
"--assert-mosh-minus-ms requires --include-mosh benchmark"
));
}
let dosh_avg = avg_ms(&dosh_times);
let mosh_avg = avg_ms(&mosh_times);
if dosh_avg + margin > mosh_avg {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh_avg:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms + {margin:.2}ms <= mosh avg {mosh_avg:.2}ms");
}
if let Some(max_ms) = args.assert_dosh_max_ms {
let dosh_avg = avg_ms(&dosh_times);
if dosh_avg > max_ms {
return Err(anyhow!(
"{dosh_label} avg {dosh_avg:.2}ms exceeded max {max_ms:.2}ms"
));
}
println!("gate ok: {dosh_label} avg {dosh_avg:.2}ms <= {max_ms:.2}ms");
if args.json {
print_json(&args, &results);
} else {
print_table(&args, &results);
}
run_assertions(&args, &results)?;
Ok(())
}
/// Paths explicitly requested via flags.
fn explicit_dosh_paths(args: &Args) -> Vec<DoshPath> {
let mut paths = Vec::new();
if args.cold_native {
paths.push(DoshPath::ColdNative);
}
if args.cached_ticket {
paths.push(DoshPath::CachedTicket);
}
if args.resume {
paths.push(DoshPath::Resume);
}
paths
}
/// The single path implied by legacy flags when no explicit path is requested.
fn legacy_dosh_path(args: &Args) -> DoshPath {
if args.local_auth {
DoshPath::LocalAuth
} else if args.warm_cache {
DoshPath::CachedTicket
} else {
DoshPath::SshBootstrap
}
}
/// True when none of the selected paths needs an SSH baseline comparison
/// (i.e. all are local-auth or cache fast paths driven without SSH).
fn dosh_only(paths: &[DoshPath]) -> bool {
paths.iter().all(|p| {
matches!(
p,
DoshPath::LocalAuth | DoshPath::CachedTicket | DoshPath::Resume
)
})
}
fn time_dosh_attach(
client: &PathBuf,
args: &Args,
path: DoshPath,
control_path: Option<&PathBuf>,
warm: bool,
) -> Result<Duration> {
let mut cmd = Command::new(client);
cmd.arg("--attach-only")
@@ -178,30 +242,66 @@ fn time_dosh_attach(
if let Some(host) = &args.dosh_host {
cmd.arg("--dosh-host").arg(host);
}
if args.local_auth {
cmd.arg("--local-auth").arg(&args.server);
} else {
cmd.arg("--ssh-port")
.arg(args.ssh_port.to_string())
.arg("--ssh-auth-command")
.arg(&args.ssh_auth_command);
if args.no_cache {
match path {
DoshPath::LocalAuth => {
cmd.arg("--local-auth");
// While warming, write the cache; measured runs honor --no-cache.
if args.no_cache && !warm {
cmd.arg("--no-cache");
}
cmd.arg(&args.server);
}
DoshPath::CachedTicket | DoshPath::Resume => {
// Fast paths must read the warmed cache, so never pass --no-cache here.
// Whether the cache uses tickets vs resume is decided by the client
// config (`cache_attach_tickets`) the harness wrote into HOME.
if args.local_auth {
cmd.arg("--local-auth");
}
add_bootstrap_args(&mut cmd, args, control_path);
cmd.arg(&args.server);
}
DoshPath::ColdNative => {
cmd.arg("--auth").arg("native");
// Cold path: never use a warmed cache.
cmd.arg("--no-cache");
add_bootstrap_args(&mut cmd, args, control_path);
cmd.arg(&args.server);
}
if let Some(key) = &args.ssh_key {
cmd.arg("--ssh-key").arg(key);
DoshPath::SshBootstrap => {
add_bootstrap_args(&mut cmd, args, control_path);
// Cold SSH bootstrap honors --no-cache on measured runs.
if args.no_cache && !warm {
cmd.arg("--no-cache");
}
cmd.arg(&args.server);
}
if let Some(known_hosts) = &args.ssh_known_hosts {
cmd.arg("--ssh-known-hosts").arg(known_hosts);
}
if let Some(control_path) = control_path {
cmd.arg("--ssh-control-path").arg(control_path);
}
cmd.arg(&args.server);
}
time_command(&mut cmd)
}
/// Append the SSH bootstrap arguments shared by the non-local paths. Native
/// auth reuses the same SSH key/known-hosts/port plumbing.
fn add_bootstrap_args(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
if args.local_auth {
return;
}
cmd.arg("--ssh-port")
.arg(args.ssh_port.to_string())
.arg("--ssh-auth-command")
.arg(&args.ssh_auth_command);
if let Some(key) = &args.ssh_key {
cmd.arg("--ssh-key").arg(key);
}
if let Some(known_hosts) = &args.ssh_known_hosts {
cmd.arg("--ssh-known-hosts").arg(known_hosts);
}
if let Some(control_path) = control_path {
cmd.arg("--ssh-control-path").arg(control_path);
}
}
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
cmd.arg("-p").arg(args.ssh_port.to_string()).arg("-T");
if let Some(key) = &args.ssh_key {
@@ -381,9 +481,194 @@ fn time_command(cmd: &mut Command) -> Result<Duration> {
Ok(start.elapsed())
}
fn avg_ms(samples: &[Duration]) -> f64 {
let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
total / samples.len() as f64
/// Per-metric samples with summary statistics.
struct MetricSamples {
name: &'static str,
samples: Vec<Duration>,
}
impl MetricSamples {
fn new(name: &'static str, samples: Vec<Duration>) -> Self {
Self { name, samples }
}
fn ms(&self) -> Vec<f64> {
self.samples
.iter()
.map(|d| d.as_secs_f64() * 1000.0)
.collect()
}
fn stats(&self) -> Stats {
Stats::from_ms(&self.ms())
}
}
/// Summary statistics for a set of millisecond samples.
struct Stats {
count: usize,
min: f64,
median: f64,
p95: f64,
mean: f64,
max: f64,
}
impl Stats {
fn from_ms(values: &[f64]) -> Self {
if values.is_empty() {
return Self {
count: 0,
min: 0.0,
median: 0.0,
p95: 0.0,
mean: 0.0,
max: 0.0,
};
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let count = sorted.len();
let mean = sorted.iter().sum::<f64>() / count as f64;
Self {
count,
min: sorted[0],
median: percentile(&sorted, 50.0),
p95: percentile(&sorted, 95.0),
mean,
max: sorted[count - 1],
}
}
}
/// Linear-interpolated percentile over a pre-sorted slice.
fn percentile(sorted: &[f64], pct: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = rank - lo as f64;
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}
}
fn print_table(args: &Args, results: &[MetricSamples]) {
if let Some(label) = &args.label {
println!("# label: {label}");
}
println!(
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max"
);
for metric in results {
let s = metric.stats();
println!(
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
// Raw per-iteration samples, so published numbers can include raw data.
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
println!("{} samples_ms=[{}]", metric.name, ms.join(", "));
}
}
fn print_json(args: &Args, results: &[MetricSamples]) {
let mut entries = Vec::new();
for metric in results {
let s = metric.stats();
let samples: Vec<String> = metric.ms().iter().map(|v| format!("{v:.4}")).collect();
entries.push(format!(
"{{\"metric\":\"{}\",\"count\":{},\"min\":{:.4},\"median\":{:.4},\"p95\":{:.4},\"mean\":{:.4},\"max\":{:.4},\"samples_ms\":[{}]}}",
metric.name,
s.count,
s.min,
s.median,
s.p95,
s.mean,
s.max,
samples.join(",")
));
}
let label = match &args.label {
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
None => "null".to_string(),
};
println!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1),
entries.join(",")
);
}
fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> {
results
.iter()
.find(|m| m.name == name)
.map(|m| m.stats().mean)
}
/// The headline dosh metric for assertions: prefer cached, then resume, then
/// cold native, then ssh-bootstrap, then local-auth.
fn primary_dosh(results: &[MetricSamples]) -> Option<(&'static str, f64)> {
const ORDER: [&str; 5] = [
"dosh_cached_attach_ms",
"dosh_resume_ms",
"dosh_cold_native_ms",
"dosh_attach_ms",
"dosh_local_attach_ms",
];
for name in ORDER {
if let Some(mean) = metric_mean(results, name) {
return Some((name, mean));
}
}
None
}
fn run_assertions(args: &Args, results: &[MetricSamples]) -> Result<()> {
if let Some(margin) = args.assert_ssh_plus_ms {
let ssh = metric_mean(results, "ssh_true_ms")
.ok_or_else(|| anyhow!("--assert-ssh-plus-ms requires non-local SSH benchmark"))?;
let (name, dosh) =
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
if dosh > ssh + margin {
return Err(anyhow!(
"{name} avg {dosh:.2}ms exceeded ssh avg {ssh:.2}ms + {margin:.2}ms"
));
}
println!("gate ok: {name} avg {dosh:.2}ms <= ssh avg {ssh:.2}ms + {margin:.2}ms");
}
if let Some(margin) = args.assert_mosh_minus_ms {
let mosh = metric_mean(results, "mosh_start_true_ms")
.ok_or_else(|| anyhow!("--assert-mosh-minus-ms requires --include-mosh benchmark"))?;
let (name, dosh) =
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
if dosh + margin > mosh {
return Err(anyhow!(
"{name} avg {dosh:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh:.2}ms"
));
}
println!("gate ok: {name} avg {dosh:.2}ms + {margin:.2}ms <= mosh avg {mosh:.2}ms");
}
if let Some(max_ms) = args.assert_dosh_max_ms {
let (name, dosh) =
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
if dosh > max_ms {
return Err(anyhow!("{name} avg {dosh:.2}ms exceeded max {max_ms:.2}ms"));
}
println!("gate ok: {name} avg {dosh:.2}ms <= {max_ms:.2}ms");
}
Ok(())
}
fn default_client_path() -> PathBuf {
+732 -50
View File
@@ -17,7 +17,7 @@ use dosh::native::{
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
NativeUserAuthBody, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
NativeUserAuthBody, PacketKind, Rekey, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
TicketAttachEnvelope, TicketAttachOkEnvelope,
};
@@ -44,6 +44,20 @@ use tokio::sync::mpsc;
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
/// Current terminal size, with a sane fallback.
///
/// `crossterm::size()` returns `Err` when stdout is not a TTY (piped), but it
/// can also return `Ok((0, 0))` for a real PTY that has not been sized yet
/// (e.g. launched under `script` with no controlling window). Sending `0` to
/// the server is meaningless and used to crash older servers, so treat any
/// zero dimension the same as a missing size and fall back to 80x24.
fn terminal_size() -> (u16, u16) {
match size() {
Ok((cols, rows)) if cols > 0 && rows > 0 => (cols, rows),
_ => (80, 24),
}
}
#[derive(Debug, Parser)]
#[command(name = "dosh-client")]
struct Args {
@@ -218,7 +232,7 @@ async fn main() -> Result<()> {
}
let dosh_port = args.dosh_port.or(host.port).unwrap_or(config.dosh_port);
let cache_path = cache_path(&config.credential_cache, &requested_server, &session, &mode);
let (cols, rows) = size().unwrap_or((80, 24));
let (cols, rows) = terminal_size();
let auth_preference = args
.auth
.clone()
@@ -2072,9 +2086,17 @@ async fn run_terminal(
let mut last_packet_at = Instant::now();
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
let mut last_size = size().unwrap_or((80, 24));
let mut last_size = terminal_size();
let mut frame_buffer = FrameBuffer::default();
let mut predictor = Predictor::new(predict && cred.mode != "view-only" && !forward_only);
// Resolve the prediction display policy (off / experimental / always). An
// env var wins for ad-hoc tuning; otherwise the client config's
// `predict_mode` provides the persistent default. Predictions only run in a
// read-write interactive session.
let predict_mode = resolve_predict_mode();
let mut predictor = Predictor::with_mode(
predict && cred.mode != "view-only" && !forward_only,
predict_mode,
);
let (forward_tx, mut forward_rx) = mpsc::channel::<ForwardEvent>(1024);
let _forward_keepalive = if local_forwards.is_empty() {
Some(forward_tx.clone())
@@ -2131,6 +2153,9 @@ async fn run_terminal(
None
};
// Retain the previous epoch's key briefly after a rekey so any in-flight
// pre-rekey frame still decrypts instead of triggering a needless reconnect.
let mut previous_session_key: Option<[u8; 32]> = None;
let mut recv_buf = vec![0u8; 65535];
loop {
tokio::select! {
@@ -2151,6 +2176,8 @@ async fn run_terminal(
}
_ = resize_tick.tick() => {
if let Ok((cols, rows)) = size()
&& cols > 0
&& rows > 0
&& (cols, rows) != last_size
{
last_size = (cols, rows);
@@ -2169,7 +2196,16 @@ async fn run_terminal(
}
match packet.header.kind {
PacketKind::Frame | PacketKind::ResumeOk => {
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
let decrypted = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)
.or_else(|err| {
// Fall back to the previous epoch key for in-flight
// pre-rekey frames before declaring the link stale.
match previous_session_key {
Some(prev) => protocol::decrypt_body(&packet, &prev, SERVER_TO_CLIENT),
None => Err(err),
}
});
let Ok(plain) = decrypted else {
if let Some(frame) = reconnect(
&socket,
&mut cred,
@@ -2209,6 +2245,48 @@ async fn run_terminal(
PacketKind::Pong => {
last_packet_at = Instant::now();
}
PacketKind::Rekey => {
// Server-initiated transport rekey (spec §11). The Rekey is
// sealed under the current key; once decrypted we derive the
// next key from the shipped fresh material + current key,
// switch atomically (keeping the old key for grace), and
// confirm with a RekeyAck under the NEW key.
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
continue;
};
let Ok(rekey) = protocol::from_body::<Rekey>(&plain) else {
continue;
};
let previous_key = cred.session_key;
let previous_key_id = cred.session_key_id;
let Ok(new_key) = dosh::native::derive_rekey_session_key(
&previous_key,
&rekey.rekey_material,
&previous_key_id,
rekey.epoch,
) else {
continue;
};
// Only accept if our derivation matches the server's id.
if protocol::session_key_id(&new_key) != rekey.new_session_key_id {
continue;
}
previous_session_key = Some(previous_key);
cred.session_key = new_key;
cred.session_key_id = rekey.new_session_key_id;
last_packet_at = Instant::now();
send_seq += 1;
let ack = protocol::encode_encrypted(
PacketKind::RekeyAck,
cred.client_id,
send_seq,
cred.last_rendered_seq,
&cred.session_key,
CLIENT_TO_SERVER,
b"",
)?;
socket.send_to(&ack, addr).await?;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body)?;
if reject.reason == "unknown client" {
@@ -2337,17 +2415,20 @@ async fn run_terminal(
continue;
};
last_packet_at = Instant::now();
let len = data.bytes.len();
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
let _ = writer.write_all(&data.bytes).await;
send_stream_window_adjust(
&socket,
addr,
&cred,
&mut send_seq,
data.stream_id,
data.bytes.len(),
).await?;
}
// Always return flow-control credit so a stream whose local
// writer is already gone cannot wedge the server's send window.
send_stream_window_adjust(
&socket,
addr,
&cred,
&mut send_seq,
data.stream_id,
len,
).await?;
}
PacketKind::StreamWindowAdjust => {
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
@@ -2894,84 +2975,521 @@ async fn send_stream_packet(
Ok(())
}
// ---------------------------------------------------------------------------
// Predictive local echo (speculative echo).
//
// This is a clean-room Rust implementation written from the design described in
// the Mosh paper (Winstein & Balakrishnan, "Mosh: An Interactive Remote Shell
// for Mobile Clients", USENIX ATC 2012) and a conceptual reading of how a
// prediction overlay behaves. No Mosh source was copied or translated; the data
// structures, byte handling, confirmation strategy, and drawing here are
// original to dosh.
//
// Why dosh's approach differs from Mosh's: Mosh runs a full terminal emulator
// on the client and keeps a cell-accurate framebuffer, so it can confirm each
// predicted character by comparing the predicted cell to the actual cell the
// server produced. dosh's client has no client-side emulator -- it blits opaque
// server byte-frames straight to the real terminal. So dosh cannot do
// content-level confirmation. Instead it confirms predictions by *frame
// sequencing*: when the server emits a new authoritative frame (output_seq
// advances) that reflects the keystrokes we predicted, that frame is rendered
// and supersedes our overlay; we erase the speculative glyphs and let the
// server's bytes stand. To draw and erase safely without an emulator, dosh
// keeps a tiny model of just the current line near the cursor.
//
// Correctness over coverage: a visibly wrong/sticky prediction is worse than no
// prediction, so anything we cannot model with confidence (escape sequences,
// control chars other than backspace, multi-byte / wide chars, the right
// margin) ends the current epoch and stops further speculation until the next
// confirmation re-bases us against authoritative output.
/// Display policy for predictions, mirroring Mosh's off / adaptive / always
/// design (we call the adaptive mode "experimental" to match Mosh's naming for
/// the SRTT-gated mode that only shows predictions on a laggy link).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PredictMode {
/// Never show predictions.
Off,
/// Show predictions only when the link is laggy (SRTT above a trigger).
Experimental,
/// Always show predictions whenever any are outstanding.
Always,
}
impl PredictMode {
fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"off" | "never" | "false" | "no" => PredictMode::Off,
"always" => PredictMode::Always,
// "experimental" / "adaptive" / "auto" / anything else -> adaptive.
_ => PredictMode::Experimental,
}
}
}
/// SRTT (in milliseconds) above which the experimental mode begins showing
/// predictions, and below which it stops. Hysteresis avoids flapping on a link
/// hovering around the threshold. On a faster-than-this link, native local echo
/// already feels instant, so speculation only adds risk.
const SRTT_TRIGGER_HIGH_MS: f64 = 30.0;
const SRTT_TRIGGER_LOW_MS: f64 = 20.0;
/// SRTT (ms) above which we additionally underline ("flag") predicted cells so
/// the user can tell speculative glyphs from confirmed ones on a very slow link.
const FLAG_TRIGGER_HIGH_MS: f64 = 80.0;
const FLAG_TRIGGER_LOW_MS: f64 = 50.0;
/// If a prediction is still outstanding after this long, treat the link as
/// laggy enough to force display even if the SRTT estimate hasn't caught up yet
/// (covers a sudden latency spike on the very first slow keystroke).
const GLITCH_FORCE_MS: u128 = 250;
/// One speculatively-echoed character on the current line.
#[derive(Clone, Debug)]
struct PredictedCell {
/// The byte we drew (always a single printable ASCII byte, 0x20..=0x7e).
byte: u8,
/// Epoch this prediction belongs to (a keystroke burst).
epoch: u64,
}
/// Predictive local-echo engine. See the module comment above for the model.
struct Predictor {
mode: PredictMode,
enabled: bool,
/// True while the server is in the alternate screen (a full-screen TUI such
/// as vim/htop); we never speculate there because we cannot model arbitrary
/// cursor addressing safely.
alternate_screen: bool,
pending: Vec<u8>,
/// Predicted cells for the current line, left-to-right starting at the
/// column where the cursor sat when this run of predictions began.
cells: Vec<PredictedCell>,
/// Cursor offset, in cells, from the start of `cells`. Equals `cells.len()`
/// while typing at the end of the line; can be less after Left-arrow or
/// backspace within the predicted span.
cursor: usize,
/// Current keystroke-burst epoch. Bumped whenever we hit something we cannot
/// model (CR/LF, escape, control byte, wide char, right margin) so those
/// speculative cells become "tentative" and stop being drawn.
epoch: u64,
/// Highest epoch confirmed by authoritative server output. Cells whose epoch
/// is greater than this are tentative and are not displayed.
confirmed_epoch: u64,
/// How many columns the current overlay occupies on screen (0 = nothing
/// drawn). Used to erase exactly what was painted before re-rendering.
drawn_width: usize,
/// Whether the last draw underlined the cells (flagging on).
drawn_flagged: bool,
// --- SRTT estimation (display-only; never gates whether input is sent) ---
/// Smoothed round-trip time estimate in milliseconds, or None until we have
/// a sample.
srtt_ms: Option<f64>,
/// When the oldest still-outstanding prediction was made. Used both to
/// sample SRTT on the next frame and to force display on a latency spike.
oldest_pending_at: Option<Instant>,
/// Hysteresis latches.
srtt_trigger: bool,
flagging: bool,
}
impl Predictor {
/// Construct with the default adaptive (experimental) display policy.
#[cfg(test)]
fn new(enabled: bool) -> Self {
Self::with_mode(enabled, PredictMode::Experimental)
}
fn with_mode(enabled: bool, mode: PredictMode) -> Self {
Self {
enabled,
mode,
enabled: enabled && mode != PredictMode::Off,
alternate_screen: false,
pending: Vec::new(),
cells: Vec::new(),
cursor: 0,
epoch: 1,
confirmed_epoch: 0,
drawn_width: 0,
drawn_flagged: false,
srtt_ms: None,
oldest_pending_at: None,
srtt_trigger: false,
flagging: false,
}
}
/// Drop all speculative state. Called on reconnect/resume and screen resize,
/// where any cached line model would be stale.
fn reset(&mut self) {
self.pending.clear();
let _ = self.erase_drawn();
self.cells.clear();
self.cursor = 0;
self.epoch += 1;
self.confirmed_epoch = self.epoch - 1;
self.alternate_screen = false;
self.oldest_pending_at = None;
}
/// Number of speculative cells currently outstanding (active in any epoch).
#[cfg(test)]
fn pending_len(&self) -> usize {
self.cells.len()
}
/// End the current keystroke burst (Mosh's "become tentative"). In dosh's
/// frame-confirmation model we cannot content-verify lingering cells, so the
/// safe choice is to drop the current epoch's speculative cells outright and
/// start a fresh epoch. This is how we bail out on anything we cannot model
/// (escape sequences, CR/LF, control bytes, wide chars, the right margin)
/// without risking a stale/wrong glyph.
fn become_tentative(&mut self) {
let _ = self.erase_drawn();
self.cells.clear();
self.cursor = 0;
self.epoch += 1;
self.oldest_pending_at = None;
}
/// Observe raw input bytes that are *also* being sent to the server. This is
/// display-only and never consumes input. The caller still forwards `bytes`
/// unconditionally.
fn observe_input(&mut self, bytes: &[u8]) -> Result<()> {
if !self.enabled || self.alternate_screen {
return Ok(());
}
if !Self::predictable(bytes) {
self.clear_pending()?;
return Ok(());
// A large burst is almost certainly a paste, not interactive typing.
// Predicting it would risk wrapping a long dim run across many lines, so
// we bail: drop any overlay and let the authoritative frame draw it.
if bytes.len() > PREDICT_BURST_LIMIT {
self.become_tentative();
return self.redraw();
}
self.pending.extend_from_slice(bytes);
self.draw_pending()
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
match b {
// Printable ASCII: append a predicted cell at the cursor.
0x20..=0x7e => {
self.predict_char(b);
i += 1;
}
// Backspace (^H) or DEL (^?): remove the previous predicted cell.
0x08 | 0x7f => {
self.predict_backspace();
i += 1;
}
// ESC: try to recognize a cursor-motion CSI/SS3 we can model
// (Left/Right within the line); otherwise become tentative.
0x1b => {
let consumed = self.predict_escape(&bytes[i..]);
if consumed == 0 {
// Unrecognized / incomplete escape: bail safely.
self.become_tentative();
i = bytes.len();
} else {
i += consumed;
}
}
// CR / LF and any other control byte: we cannot model where the
// cursor lands (shell prompt, scroll, etc.), so end the epoch.
_ => {
self.become_tentative();
i += 1;
}
}
}
if self.oldest_pending_at.is_none() && !self.cells.is_empty() {
self.oldest_pending_at = Some(Instant::now());
}
self.redraw()
}
/// Predict one printable character at the current cursor position.
fn predict_char(&mut self, byte: u8) {
// Right margin is tricky (shells, editors, and wrap behavior differ), so
// we refuse to predict past a conservative column budget and bail out
// instead of risking a wrong glyph at a wrap point.
if self.cells.len() >= PREDICT_MAX_LINE {
self.become_tentative();
return;
}
let cell = PredictedCell {
byte,
epoch: self.epoch,
};
if self.cursor < self.cells.len() {
// Typing over an existing predicted cell (after a Left arrow):
// overwrite in place, as a terminal in insert-off mode would.
self.cells[self.cursor] = cell;
} else {
self.cells.push(cell);
}
self.cursor += 1;
}
/// Predict a backspace: erase the previous predicted cell if we have one.
fn predict_backspace(&mut self) {
if self.cursor == 0 {
// We'd be backspacing past the start of our predicted span into
// territory we don't model -> bail safely.
self.become_tentative();
return;
}
self.cursor -= 1;
// Only safe to shrink the line if we're at its end; otherwise a
// mid-line backspace shifts everything left, which we don't model, so
// bail.
if self.cursor == self.cells.len() - 1 {
self.cells.pop();
} else {
self.become_tentative();
}
}
/// Recognize Left/Right cursor motion escape sequences we can model and
/// apply them to the local cursor. Returns the number of bytes consumed, or
/// 0 if `data` does not start with a sequence we handle.
///
/// Improvement over the previous dosh predictor (which bailed on any escape)
/// and parity with Mosh: predicting in-line arrow-key motion so cursor
/// repositioning feels instant too.
fn predict_escape(&mut self, data: &[u8]) -> usize {
// Both CSI (ESC [) and application-cursor SS3 (ESC O) are used for arrows.
if data.len() < 3 || data[0] != 0x1b {
return 0;
}
if data[1] != b'[' && data[1] != b'O' {
return 0;
}
match data[2] {
// Right arrow: only within already-predicted cells.
b'C' if self.cursor < self.cells.len() => {
self.cursor += 1;
3
}
// Left arrow.
b'D' if self.cursor > 0 => {
self.cursor -= 1;
3
}
// Recognized arrow but at the edge of our predicted span, or
// Up/Down/Home/End/etc. which cross lines or jump unpredictably:
// refuse so the caller becomes tentative rather than mispredicting.
_ => 0,
}
}
/// Observe authoritative server output. Detects alternate-screen transitions
/// and confirms outstanding predictions: a fresh frame means the server has
/// re-rendered the line, so our speculative glyphs are superseded and erased.
fn observe_output(&mut self, bytes: &[u8]) {
if contains_bytes(bytes, b"\x1b[?1049h")
|| contains_bytes(bytes, b"\x1b[?1047h")
|| contains_bytes(bytes, b"\x1b[?47h")
{
self.alternate_screen = true;
self.pending.clear();
let _ = self.discard_all();
}
if contains_bytes(bytes, b"\x1b[?1049l")
|| contains_bytes(bytes, b"\x1b[?1047l")
|| contains_bytes(bytes, b"\x1b[?47l")
{
self.alternate_screen = false;
self.pending.clear();
let _ = self.discard_all();
}
}
/// Sample SRTT from a newly arrived frame and confirm/clear outstanding
/// predictions. Called once per accepted authoritative frame, *before* the
/// frame's bytes are written to the terminal so the server render lands on a
/// clean line. Returns nothing; drawing is handled by the caller's render
/// followed by `redraw` on the next input.
fn confirm_with_frame(&mut self) -> Result<()> {
if let Some(sent_at) = self.oldest_pending_at.take() {
let sample = sent_at.elapsed().as_secs_f64() * 1000.0;
self.update_srtt(sample);
}
// The frame is authoritative for the line; clear our overlay so the
// server's bytes are the only thing on screen. This is dosh's
// confirmation: frame arrival == the predicted keystrokes are now echoed
// by the server. Faster than waiting for per-cell content match.
self.discard_all()
}
fn update_srtt(&mut self, sample_ms: f64) {
// Standard exponentially weighted moving average (RFC 6298 style alpha).
const ALPHA: f64 = 0.125;
self.srtt_ms = Some(match self.srtt_ms {
None => sample_ms,
Some(prev) => (1.0 - ALPHA) * prev + ALPHA * sample_ms,
});
}
/// Test seam: pin the SRTT estimate and refresh the display triggers so the
/// experimental-mode threshold behavior can be exercised deterministically.
#[cfg(test)]
fn set_srtt_for_test(&mut self, ms: f64) {
self.srtt_ms = Some(ms);
self.update_triggers();
}
/// Whether we should *display* predictions right now under the active policy.
fn should_display(&self) -> bool {
match self.mode {
PredictMode::Off => false,
PredictMode::Always => true,
PredictMode::Experimental => {
if self.srtt_trigger {
return true;
}
// Force display on a latency spike even before SRTT catches up.
if let Some(at) = self.oldest_pending_at
&& at.elapsed().as_millis() >= GLITCH_FORCE_MS
{
return true;
}
false
}
}
}
/// Update the SRTT/flag hysteresis latches from the current estimate.
fn update_triggers(&mut self) {
let srtt = self.srtt_ms.unwrap_or(0.0);
if srtt > SRTT_TRIGGER_HIGH_MS {
self.srtt_trigger = true;
} else if srtt <= SRTT_TRIGGER_LOW_MS {
self.srtt_trigger = false;
}
if srtt > FLAG_TRIGGER_HIGH_MS {
self.flagging = true;
} else if srtt <= FLAG_TRIGGER_LOW_MS {
self.flagging = false;
}
}
/// The active (displayable) predicted bytes: cells in confirmed-or-current
/// epochs only, drawn from the start of the line up to the cursor budget.
fn visible_bytes(&self) -> Vec<u8> {
self.cells
.iter()
.filter(|c| c.epoch > self.confirmed_epoch || c.epoch == self.epoch)
.map(|c| c.byte)
.collect()
}
/// Repaint the prediction overlay: erase the old one, draw the new one if
/// policy says so. Uses save/restore cursor so it never moves the real
/// cursor and, when nothing should show, leaves the terminal untouched.
fn redraw(&mut self) -> Result<()> {
self.update_triggers();
// Always erase whatever we drew before so we never leave a stale glyph.
self.erase_drawn()?;
if !self.should_display() {
return Ok(());
}
let bytes = self.visible_bytes();
if bytes.is_empty() {
return Ok(());
}
let flag = self.flagging;
// Save cursor, dim (and optionally underline) predicted glyphs, draw,
// reset attributes, restore cursor.
let mut out = Vec::with_capacity(bytes.len() + 12);
out.extend_from_slice(b"\x1b7");
out.extend_from_slice(if flag { b"\x1b[2;4m" } else { b"\x1b[2m" });
out.extend_from_slice(&bytes);
out.extend_from_slice(b"\x1b[0m\x1b8");
emit_overlay(&out)?;
// Record exactly how many columns we painted so erasure overwrites the
// right amount even if the model changes before the next repaint.
self.drawn_width = bytes.len();
self.drawn_flagged = flag;
let _ = self.drawn_flagged;
Ok(())
}
/// Erase the currently drawn overlay (if any) by overwriting the previously
/// painted columns with spaces, using save/restore cursor so the real cursor
/// and surrounding text are untouched.
fn erase_drawn(&mut self) -> Result<()> {
let width = self.drawn_width;
self.drawn_width = 0;
if width == 0 {
return Ok(());
}
let mut out = Vec::with_capacity(width + 4);
out.extend_from_slice(b"\x1b7");
out.resize(out.len() + width, b' ');
out.extend_from_slice(b"\x1b8");
emit_overlay(&out)
}
/// Erase the overlay and drop all speculative state, advancing the confirmed
/// epoch so nothing lingers.
fn discard_all(&mut self) -> Result<()> {
self.erase_drawn()?;
self.cells.clear();
self.cursor = 0;
self.epoch += 1;
self.confirmed_epoch = self.epoch - 1;
self.oldest_pending_at = None;
Ok(())
}
/// Erase the overlay without dropping the SRTT estimate or model; used right
/// before an authoritative frame is rendered so its bytes land cleanly.
/// (Kept as the public name the run loop calls.)
fn clear_pending(&mut self) -> Result<()> {
self.confirm_with_frame()
}
/// Whether a byte sequence is a plain printable run. Retained as a small
/// predicate exercised by the unit tests.
#[cfg(test)]
fn predictable(bytes: &[u8]) -> bool {
!bytes.is_empty() && bytes.iter().all(|byte| matches!(byte, 0x20..=0x7e))
}
}
fn draw_pending(&self) -> Result<()> {
if self.pending.is_empty() {
return Ok(());
}
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b7\x1b[4m")?;
stdout.write_all(&self.pending)?;
stdout.write_all(b"\x1b[24m\x1b8")?;
stdout.flush()?;
Ok(())
/// Conservative cap on how many predicted cells we keep on one line before we
/// stop speculating (a backstop against runaway predictions; the right-margin /
/// wrap point is genuinely ambiguous without a client-side emulator).
const PREDICT_MAX_LINE: usize = 256;
/// Largest single input burst we treat as interactive typing. Anything larger
/// is assumed to be a paste and is not speculated (would risk a long dim run
/// wrapping across lines before the server frame supersedes it).
const PREDICT_BURST_LIMIT: usize = 32;
/// Write a prepared overlay byte sequence to the real terminal. Suppressed
/// under `cfg(test)` so unit tests exercise the prediction model without
/// emitting escape sequences to the test runner's terminal.
#[cfg(not(test))]
fn emit_overlay(bytes: &[u8]) -> Result<()> {
let mut stdout = std::io::stdout();
stdout.write_all(bytes)?;
stdout.flush()?;
Ok(())
}
#[cfg(test)]
fn emit_overlay(_bytes: &[u8]) -> Result<()> {
Ok(())
}
/// Resolve the prediction display policy. `DOSH_PREDICT_MODE` (off / experimental
/// / always) overrides for quick experiments; otherwise the client config's
/// `predict_mode` is used, defaulting to the adaptive (experimental) policy.
fn resolve_predict_mode() -> PredictMode {
if let Ok(env) = std::env::var("DOSH_PREDICT_MODE") {
return PredictMode::parse(&env);
}
fn clear_pending(&mut self) -> Result<()> {
if self.pending.is_empty() {
return Ok(());
}
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b7")?;
for _ in 0..self.pending.len() {
stdout.write_all(b" ")?;
}
stdout.write_all(b"\x1b8")?;
stdout.flush()?;
self.pending.clear();
Ok(())
match load_client_config(None) {
Ok(cfg) => PredictMode::parse(&cfg.predict_mode),
Err(_) => PredictMode::Experimental,
}
}
@@ -3137,8 +3655,8 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
#[cfg(test)]
mod tests {
use super::{
DynamicForward, FrameBuffer, LocalForward, Predictor, RemoteForward, SshConfig,
auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward,
DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor, RemoteForward,
SshConfig, auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward,
parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table,
recv_response_until, requested_env, ssh_destination_host, ssh_username, ssh_with_user,
startup_command, toml_bare_key_or_quoted,
@@ -3514,6 +4032,170 @@ mod tests {
assert!(!predictor.alternate_screen);
}
/// Typing printable characters builds an in-order predicted line and the
/// local cursor tracks the end of it.
#[test]
fn predictor_predicts_printable_run() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
p.observe_input(b"hi").unwrap();
assert_eq!(p.visible_bytes(), b"hi");
assert_eq!(p.cursor, 2);
assert_eq!(p.pending_len(), 2);
}
/// An authoritative server frame confirms (and clears) outstanding
/// predictions: after a frame the overlay is empty.
#[test]
fn predictor_confirmation_clears_prediction() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
p.observe_input(b"abc").unwrap();
assert_eq!(p.pending_len(), 3);
// Server echoes the keystrokes; a new frame arrives.
p.clear_pending().unwrap(); // run loop calls this before render_frame
assert_eq!(p.pending_len(), 0);
assert!(p.visible_bytes().is_empty());
}
/// A glitch -- server output that contradicts the prediction (here the
/// server enters the alternate screen mid-burst) -- discards the epoch's
/// predictions so nothing wrong is left on screen.
#[test]
fn predictor_glitch_discards_epoch() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
p.observe_input(b"vim").unwrap();
assert_eq!(p.pending_len(), 3);
let epoch_before = p.epoch;
// Server diverges: enters a full-screen TUI. Our line prediction is now
// meaningless and must be dropped.
p.observe_output(b"\x1b[?1049h");
assert!(p.alternate_screen);
assert_eq!(p.pending_len(), 0);
// The contradicted epoch is fully retired (confirmed past).
assert!(p.confirmed_epoch >= epoch_before);
// While in the alternate screen we refuse to speculate at all.
p.observe_input(b"x").unwrap();
assert_eq!(p.pending_len(), 0);
}
/// Backspace prediction removes the last predicted cell from the end of the
/// line and moves the local cursor back.
#[test]
fn predictor_predicts_backspace() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
p.observe_input(b"hello").unwrap();
assert_eq!(p.visible_bytes(), b"hello");
p.observe_input(b"\x7f").unwrap(); // DEL
assert_eq!(p.visible_bytes(), b"hell");
assert_eq!(p.cursor, 4);
p.observe_input(&[0x08]).unwrap(); // ^H
assert_eq!(p.visible_bytes(), b"hel");
assert_eq!(p.cursor, 3);
// Backspacing past the start of our predicted span bails safely (we
// don't model what's to the left), ending the epoch with no glyphs.
p.observe_input(b"\x7f\x7f\x7f\x7f").unwrap();
assert_eq!(p.pending_len(), 0);
}
/// Right-margin / line-wrap edge: we refuse to predict past a conservative
/// per-line budget instead of risking a wrong glyph at the wrap point.
/// Bytes are fed in small interactive bursts so the per-line budget (not the
/// paste guard) is what trips.
#[test]
fn predictor_line_wrap_edge_bails_at_budget() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
let chunk = vec![b'x'; super::PREDICT_BURST_LIMIT];
let mut typed = 0;
while typed + chunk.len() <= super::PREDICT_MAX_LINE {
p.observe_input(&chunk).unwrap();
typed += chunk.len();
}
assert_eq!(p.pending_len(), super::PREDICT_MAX_LINE);
// One more character crosses the budget -> bail, drop the epoch.
p.observe_input(b"y").unwrap();
assert_eq!(p.pending_len(), 0);
}
/// A paste-sized burst is not speculated at all (no overlay), but the input
/// is still forwarded by the caller -- prediction is display-only.
#[test]
fn predictor_skips_paste_sized_burst() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
let paste = vec![b'a'; super::PREDICT_BURST_LIMIT + 1];
p.observe_input(&paste).unwrap();
assert_eq!(p.pending_len(), 0);
}
/// Carriage return / newline cannot be modeled (prompt, scroll), so it ends
/// the epoch and clears the speculative line.
#[test]
fn predictor_newline_ends_epoch() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
p.observe_input(b"ls").unwrap();
assert_eq!(p.pending_len(), 2);
p.observe_input(b"\r").unwrap();
assert_eq!(p.pending_len(), 0);
}
/// In-line arrow-key motion (improvement over the old printable-only
/// predictor): Left/Right move the local cursor within the predicted span
/// and a following keystroke overwrites in place.
#[test]
fn predictor_predicts_inline_arrows() {
let mut p = Predictor::with_mode(true, PredictMode::Always);
p.observe_input(b"abc").unwrap();
assert_eq!(p.cursor, 3);
p.observe_input(b"\x1b[D").unwrap(); // left
assert_eq!(p.cursor, 2);
p.observe_input(b"\x1bOD").unwrap(); // left (application/SS3)
assert_eq!(p.cursor, 1);
p.observe_input(b"\x1b[C").unwrap(); // right
assert_eq!(p.cursor, 2);
// Overwrite the cell at the cursor (index 2, the 'c') in place.
p.observe_input(b"Z").unwrap();
assert_eq!(p.visible_bytes(), b"abZ");
assert_eq!(p.cursor, 3);
}
/// Experimental (adaptive) mode shows no prediction while SRTT is below the
/// trigger, and shows it once SRTT rises above the trigger.
#[test]
fn predictor_experimental_respects_srtt_threshold() {
let mut p = Predictor::with_mode(true, PredictMode::Experimental);
// Fast link: no fresh prediction yet, no SRTT sample -> not displayed.
p.set_srtt_for_test(5.0);
p.observe_input(b"abc").unwrap();
assert!(p.pending_len() > 0); // model still tracks the prediction...
assert!(!p.should_display()); // ...but policy hides it on a fast link.
// Slow link: SRTT above the high trigger -> predictions are displayed.
p.set_srtt_for_test(60.0);
assert!(p.should_display());
}
/// Always mode displays regardless of SRTT; Off never does.
#[test]
fn predictor_mode_policies() {
let mut always = Predictor::with_mode(true, PredictMode::Always);
always.observe_input(b"x").unwrap();
assert!(always.should_display());
let off = Predictor::with_mode(true, PredictMode::Off);
assert!(!off.enabled);
assert!(!off.should_display());
assert_eq!(PredictMode::parse("off"), PredictMode::Off);
assert_eq!(PredictMode::parse("ALWAYS"), PredictMode::Always);
assert_eq!(PredictMode::parse("adaptive"), PredictMode::Experimental);
assert_eq!(PredictMode::parse("anything"), PredictMode::Experimental);
}
#[test]
fn startup_command_joins_args_for_remote_shell() {
assert_eq!(startup_command(&[]), None);
+778 -210
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -28,6 +28,14 @@ pub struct ServerConfig {
pub authorized_keys: Vec<String>,
#[serde(default = "default_native_auth_rate_limit_per_minute")]
pub native_auth_rate_limit_per_minute: u32,
/// Rotate a client's transport traffic key after this many packets in the
/// current epoch (spec §11). `0` disables the packet-count trigger.
#[serde(default = "default_rekey_after_packets")]
pub rekey_after_packets: u64,
/// Rotate a client's transport traffic key after this many wall-clock seconds
/// in the current epoch (spec §11). `0` disables the time trigger.
#[serde(default = "default_rekey_after_secs")]
pub rekey_after_secs: u64,
#[serde(default = "default_true")]
pub allow_tcp_forwarding: bool,
#[serde(default)]
@@ -61,6 +69,8 @@ impl Default for ServerConfig {
host_key: default_host_key(),
authorized_keys: default_authorized_keys(),
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
rekey_after_packets: default_rekey_after_packets(),
rekey_after_secs: default_rekey_after_secs(),
allow_tcp_forwarding: true,
allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false,
@@ -84,6 +94,10 @@ pub struct ClientConfig {
pub view_only: bool,
#[serde(default)]
pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown.
#[serde(default = "default_predict_mode")]
pub predict_mode: String,
pub cache_attach_tickets: bool,
pub credential_cache: String,
#[serde(default = "default_auth_preference")]
@@ -141,6 +155,7 @@ impl Default for ClientConfig {
reconnect_timeout_secs: 5,
view_only: false,
predict: false,
predict_mode: default_predict_mode(),
cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(),
auth_preference: default_auth_preference(),
@@ -175,6 +190,18 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
30
}
fn default_rekey_after_packets() -> u64 {
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
// keeps forward-secrecy windows small.
100_000
}
fn default_rekey_after_secs() -> u64 {
// One hour per epoch by default.
3600
}
fn default_auth_preference() -> String {
"native,ssh".to_string()
}
@@ -183,6 +210,10 @@ fn default_native_auth_timeout_ms() -> u64 {
700
}
fn default_predict_mode() -> String {
"experimental".to_string()
}
fn default_known_hosts() -> String {
"~/.config/dosh/known_hosts".to_string()
}
+79 -11
View File
@@ -15,7 +15,7 @@ use std::str::FromStr;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
pub const NATIVE_PROTOCOL_VERSION: u8 = 1;
pub const NATIVE_PROTOCOL_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostPublicKey {
@@ -248,17 +248,55 @@ pub fn sign_server_hello(
Ok(())
}
/// Error raised when a native handshake peer speaks a different protocol version.
///
/// Carries the peer's advertised version so callers can render an actionable,
/// named message ("upgrade dosh") rather than letting a mismatch surface as an
/// opaque decrypt failure or a silent timeout. The `Display` text deliberately
/// embeds [`crate::protocol::VERSION_MISMATCH_REASON`] so the server's reject and
/// the client's local error read the same way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolVersionMismatch {
pub local: u8,
pub remote: u8,
pub peer: &'static str,
}
impl std::fmt::Display for ProtocolVersionMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({} speaks native protocol v{}, this build speaks v{})",
crate::protocol::VERSION_MISMATCH_REASON,
self.peer,
self.remote,
self.local,
)
}
}
impl std::error::Error for ProtocolVersionMismatch {}
/// Verify a peer-advertised native protocol version against this build.
///
/// `peer` names whose version was wrong ("client" or "server") for the error
/// message. Returns a typed [`ProtocolVersionMismatch`] so the caller can both
/// match on it and print an actionable, upgrade-oriented message.
pub fn check_native_protocol_version(version: u8, peer: &'static str) -> Result<()> {
if version != NATIVE_PROTOCOL_VERSION {
return Err(ProtocolVersionMismatch {
local: NATIVE_PROTOCOL_VERSION,
remote: version,
peer,
}
.into());
}
Ok(())
}
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
anyhow::ensure!(
client.protocol_version == NATIVE_PROTOCOL_VERSION,
"unsupported native client protocol {}",
client.protocol_version
);
anyhow::ensure!(
server.protocol_version == NATIVE_PROTOCOL_VERSION,
"unsupported native server protocol {}",
server.protocol_version
);
check_native_protocol_version(client.protocol_version, "client")?;
check_native_protocol_version(server.protocol_version, "server")?;
let transcript = server_hello_transcript(client, server)?;
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
}
@@ -369,6 +407,36 @@ pub fn derive_native_session_key(
crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1")
}
/// Derive a rotated transport key for transport rekey (spec §11 / §9).
///
/// The rotated key is derived **independently of the handshake traffic keys**:
/// the input keying material is the current epoch's key (which both peers already
/// hold) mixed with fresh, server-generated `rekey_material` (32 bytes of CSPRNG
/// output, delivered confidentially inside an AEAD `Rekey` packet sealed under
/// the current key). The new `epoch` and the previous epoch's `session_key_id`
/// salt the derivation so each epoch's key is unique. It never re-derives from
/// the handshake DH output, satisfying the spec requirement that "rotated session
/// keys must be derived independently ... from fresh randomness" and "must not
/// reuse handshake traffic keys."
///
/// Both peers run this identically — the client never needs the server's
/// long-term secret, only the fresh `rekey_material` it receives in the `Rekey`
/// packet plus the current key it already shares.
pub fn derive_rekey_session_key(
current_key: &[u8; 32],
rekey_material: &[u8; 32],
previous_session_key_id: &[u8; 16],
epoch: u64,
) -> Result<[u8; 32]> {
let mut ikm = Vec::with_capacity(64);
ikm.extend_from_slice(current_key);
ikm.extend_from_slice(rekey_material);
let mut salt = b"dosh/native/rekey/v1".to_vec();
salt.extend_from_slice(previous_session_key_id);
salt.extend_from_slice(&epoch.to_be_bytes());
crypto::hkdf32(&ikm, &salt, b"dosh/native/rekey/chacha20poly1305/v1")
}
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
load_ed25519_identity_with_passphrase(path, None)
}
+44 -2
View File
@@ -5,8 +5,14 @@ use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const MAGIC: &[u8; 4] = b"DOSH";
pub const VERSION: u8 = 1;
pub const VERSION: u8 = 2;
pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
/// native handshake arrives carrying a `protocol_version` it cannot speak. The
/// client recognizes this prefix and surfaces a clear "upgrade dosh" message
/// instead of the generic transport rejection or, worse, a silent timeout.
pub const VERSION_MISMATCH_REASON: &str = "protocol version mismatch — upgrade dosh";
const HEADER_AAD_LEN: usize = HEADER_LEN - 2;
pub const CLIENT_TO_SERVER: u32 = 1;
pub const SERVER_TO_CLIENT: u32 = 2;
@@ -39,6 +45,8 @@ pub enum PacketKind {
StreamEof = 23,
StreamClose = 24,
NativeAuthCheckOk = 25,
Rekey = 26,
RekeyAck = 27,
}
impl TryFrom<u8> for PacketKind {
@@ -71,6 +79,8 @@ impl TryFrom<u8> for PacketKind {
23 => Self::StreamEof,
24 => Self::StreamClose,
25 => Self::NativeAuthCheckOk,
26 => Self::Rekey,
27 => Self::RekeyAck,
_ => bail!("unknown packet kind {value}"),
})
}
@@ -110,7 +120,11 @@ impl Header {
bail!("bad magic");
}
if input[4] != VERSION {
bail!("bad protocol version {}", input[4]);
bail!(
"{} (peer wire protocol v{}, this build speaks v{VERSION})",
VERSION_MISMATCH_REASON,
input[4]
);
}
let kind = PacketKind::try_from(input[5])?;
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
@@ -133,6 +147,20 @@ impl Header {
}
}
/// Cheaply inspect a datagram that carries our [`MAGIC`] but whose wire
/// [`VERSION`] byte differs from this build's. Returns the peer's advertised
/// wire version when (and only when) the packet is a Dosh packet we cannot
/// otherwise decode because of a version skew, so the receiver can answer with a
/// clear, named version-mismatch reject instead of dropping it (a silent
/// timeout for the peer). Returns `None` for our own version, foreign magic, or
/// runt packets.
pub fn peek_foreign_wire_version(input: &[u8]) -> Option<u8> {
if input.len() < 5 || &input[..4] != MAGIC || input[4] == VERSION {
return None;
}
Some(input[4])
}
#[derive(Debug, Clone)]
pub struct Packet {
pub header: Header,
@@ -369,6 +397,20 @@ pub struct StreamClose {
pub stream_id: u64,
}
/// Server→client transport rekey, sealed under the *current* session key.
///
/// Carries the fresh server-generated `rekey_material` and the new `epoch`; both
/// peers feed these into [`crate::native::derive_rekey_session_key`] to compute
/// the next traffic key. `new_session_key_id` is the id the next epoch's packets
/// will carry, so the client can recognize and switch atomically. The client
/// replies with a [`PacketKind::RekeyAck`] encrypted under the *new* key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rekey {
pub epoch: u64,
pub rekey_material: [u8; 32],
pub new_session_key_id: [u8; 16],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frame {
pub session: String,
+23 -2
View File
@@ -1,5 +1,5 @@
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
@@ -8,6 +8,7 @@ use tokio::sync::mpsc;
pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
child: Mutex<Box<dyn Child + Send + Sync>>,
_master: Box<dyn MasterPty + Send>,
}
@@ -28,6 +29,25 @@ impl PtyHandle {
})?;
Ok(())
}
/// Terminate the shell process backing this PTY and reap it.
///
/// Without this the child shell outlives the session: dropping the master
/// alone is not guaranteed to take the process down, and the `Child` handle
/// used to be discarded at spawn time, which leaked one shell per
/// abandoned session.
pub fn kill(&self) {
if let Ok(mut child) = self.child.lock() {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl Drop for PtyHandle {
fn drop(&mut self) {
self.kill();
}
}
#[derive(Debug)]
@@ -68,7 +88,7 @@ pub fn spawn_pty_session(
} else if let Some(parent) = Path::new(shell).parent() {
cmd.env("PWD", parent.as_os_str());
}
let _child = pair.slave.spawn_command(cmd).context("spawn shell")?;
let child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
let writer = pair.master.take_writer().context("take pty writer")?;
@@ -110,6 +130,7 @@ pub fn spawn_pty_session(
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
child: Mutex::new(child),
_master: pair.master,
})
}
+781
View File
@@ -0,0 +1,781 @@
//! Hostile-network integration tests (Track B, spec milestone 5 / §16).
//!
//! These spin up a real `dosh-server` process bound to 127.0.0.1 on a free port
//! in a temp HOME, and drive the wire protocol directly from the test (mirroring
//! the `direct_attach` pattern in tests/integration_smoke.rs). Between the test
//! "client" and the server sits an in-process UDP relay/shim that can drop,
//! reorder, and duplicate datagrams, and can switch the client's source address
//! (by rebinding its upstream socket) mid-session.
//!
//! Assertions, mapped to §16 verification items:
//! * "Stale encrypted packets after reconnect are ignored, not fatal"
//! -> session survives loss/reorder; stale packets after resume don't kill it.
//! * "Replayed transport packets are rejected" / "no double-apply"
//! -> duplicated & replayed Input is applied at most once.
//! * "Client IP/port change preserves the session"
//! -> after the relay rebinds its upstream socket the session keeps working.
//!
//! Determinism: the relay's drop/reorder/dup behavior is driven by a fixed-seed
//! PRNG and by explicit one-shot toggles, never by wall-clock timing, so the
//! tests are reproducible and fast.
use std::fs;
use std::net::{SocketAddr, UdpSocket};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use std::time::{Duration, Instant};
use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config;
use dosh::crypto;
use dosh::protocol::{
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest,
SERVER_TO_CLIENT,
};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
fn free_udp_port() -> u16 {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket.local_addr().unwrap().port()
}
fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf {
let config_dir = dir.path().join(".config/dosh");
fs::create_dir_all(&config_dir).unwrap();
let config = config_dir.join("server.toml");
fs::write(
&config,
format!(
r#"
port = {port}
bind = "127.0.0.1"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 30
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "/bin/sh"
sessions_dir = "{sessions}"
secret_path = "{secret}"
host_key = "{host_key}"
authorized_keys = ["{authorized_keys}"]
"#,
sessions = dir.path().join("sessions").display(),
secret = dir.path().join("secret").display(),
host_key = dir.path().join("host_key").display(),
authorized_keys = dir.path().join("authorized_keys").display(),
),
)
.unwrap();
config
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server)
.arg("serve")
.arg("--config")
.arg(config)
.env("HOME", dir.path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(500));
child
}
/// Knobs that control how the relay mishandles datagrams. All knobs default to
/// pass-through. Each is read on every forwarded packet.
struct RelayControls {
/// Probability [0,100] that an upstream (client->server) packet is dropped.
drop_c2s_percent: AtomicU64,
/// Probability [0,100] that a downstream (server->client) packet is dropped.
drop_s2c_percent: AtomicU64,
/// Probability [0,100] that a packet (either direction) is duplicated.
dup_percent: AtomicU64,
/// When set, the relay holds one packet back and releases it after the next
/// one passes, producing a 2-1 reorder. Used as a deterministic toggle.
reorder_next: AtomicBool,
/// Count of upstream packets the relay observed (for assertions).
c2s_count: AtomicU64,
/// Count of downstream packets the relay observed.
s2c_count: AtomicU64,
shutdown: AtomicBool,
}
impl RelayControls {
fn new() -> Self {
Self {
drop_c2s_percent: AtomicU64::new(0),
drop_s2c_percent: AtomicU64::new(0),
dup_percent: AtomicU64::new(0),
reorder_next: AtomicBool::new(false),
c2s_count: AtomicU64::new(0),
s2c_count: AtomicU64::new(0),
shutdown: AtomicBool::new(false),
}
}
}
/// Command sent to the relay from the test thread.
enum RelayCmd {
/// Rebind the upstream (toward-server) socket to a fresh local address,
/// simulating a client NAT rebind / IP-port change. Replies the new addr.
RebindUpstream(Sender<SocketAddr>),
}
/// A UDP relay sitting between the test client and the real server.
///
/// `front` is the address the test client sends to. The relay forwards each
/// client datagram to the server over `upstream`, remembering the client's
/// address so server replies can be returned. `upstream` can be replaced on
/// demand to simulate a client source-address change as the server observes it.
struct Relay {
front_addr: SocketAddr,
controls: Arc<RelayControls>,
cmd_tx: Sender<RelayCmd>,
handle: Option<thread::JoinHandle<()>>,
}
impl Relay {
fn spawn(server_port: u16, seed: u64) -> Self {
let front = UdpSocket::bind("127.0.0.1:0").unwrap();
front
.set_read_timeout(Some(Duration::from_millis(20)))
.unwrap();
let front_addr = front.local_addr().unwrap();
let server_addr: SocketAddr = format!("127.0.0.1:{server_port}").parse().unwrap();
let controls = Arc::new(RelayControls::new());
let (cmd_tx, cmd_rx) = channel::<RelayCmd>();
let thread_controls = Arc::clone(&controls);
let handle = thread::spawn(move || {
relay_loop(front, server_addr, thread_controls, cmd_rx, seed);
});
Self {
front_addr,
controls,
cmd_tx,
handle: Some(handle),
}
}
fn front_addr(&self) -> SocketAddr {
self.front_addr
}
fn set_drop_c2s(&self, percent: u64) {
self.controls
.drop_c2s_percent
.store(percent, Ordering::SeqCst);
}
fn set_drop_s2c(&self, percent: u64) {
self.controls
.drop_s2c_percent
.store(percent, Ordering::SeqCst);
}
fn set_dup(&self, percent: u64) {
self.controls.dup_percent.store(percent, Ordering::SeqCst);
}
fn arm_reorder(&self) {
self.controls.reorder_next.store(true, Ordering::SeqCst);
}
fn clear_impairments(&self) {
self.set_drop_c2s(0);
self.set_drop_s2c(0);
self.set_dup(0);
}
/// Rebind the relay's upstream socket; the server will see traffic from a
/// new source address afterward. Returns the new upstream local address.
fn rebind_upstream(&self) -> SocketAddr {
let (tx, rx) = channel();
self.cmd_tx.send(RelayCmd::RebindUpstream(tx)).unwrap();
rx.recv_timeout(Duration::from_secs(2))
.expect("relay rebind ack")
}
}
impl Drop for Relay {
fn drop(&mut self) {
self.controls.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
fn relay_loop(
front: UdpSocket,
server_addr: SocketAddr,
controls: Arc<RelayControls>,
cmd_rx: Receiver<RelayCmd>,
seed: u64,
) {
let mut upstream = new_upstream();
let mut client_addr: Option<SocketAddr> = None;
let mut rng = StdRng::seed_from_u64(seed);
let mut held: Option<(Vec<u8>, bool)> = None; // (packet, is_c2s) held for reorder
let mut buf = [0u8; 65535];
loop {
if controls.shutdown.load(Ordering::SeqCst) {
return;
}
// Process any pending control commands.
while let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
RelayCmd::RebindUpstream(reply) => {
upstream = new_upstream();
let _ = reply.send(upstream.local_addr().unwrap());
}
}
}
// Client -> server.
match front.recv_from(&mut buf) {
Ok((n, src)) => {
client_addr = Some(src);
controls.c2s_count.fetch_add(1, Ordering::SeqCst);
let packet = buf[..n].to_vec();
let drop_pct = controls.drop_c2s_percent.load(Ordering::SeqCst);
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
forward_with_effects(
&upstream,
server_addr,
packet,
true,
&controls,
&mut rng,
&mut held,
);
}
}
Err(ref e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
// Server -> client.
match upstream.recv_from(&mut buf) {
Ok((n, _src)) => {
controls.s2c_count.fetch_add(1, Ordering::SeqCst);
if let Some(dst) = client_addr {
let packet = buf[..n].to_vec();
let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst);
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
forward_with_effects(
&front, dst, packet, false, &controls, &mut rng, &mut held,
);
}
}
}
Err(ref e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
}
fn new_upstream() -> UdpSocket {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(20)))
.unwrap();
socket
}
/// Forward a packet to `dst` over `out`, applying duplication and reorder.
fn forward_with_effects(
out: &UdpSocket,
dst: SocketAddr,
packet: Vec<u8>,
is_c2s: bool,
controls: &RelayControls,
rng: &mut StdRng,
held: &mut Option<(Vec<u8>, bool)>,
) {
// Reorder: if armed, hold this packet and release the previously held one
// afterward (so two consecutive packets swap order).
if controls.reorder_next.swap(false, Ordering::SeqCst) {
if let Some((prev, _)) = held.take() {
let _ = out.send_to(&packet, dst);
let _ = out.send_to(&prev, dst);
return;
}
*held = Some((packet, is_c2s));
return;
}
if let Some((prev, _)) = held.take() {
let _ = out.send_to(&prev, dst);
}
let _ = out.send_to(&packet, dst);
let dup_pct = controls.dup_percent.load(Ordering::SeqCst);
if dup_pct > 0 && rng.gen_range(0..100) < dup_pct {
let _ = out.send_to(&packet, dst);
}
}
/// Build a bootstrap and attach through the relay, returning the client socket,
/// the bootstrap (for the session key), and the AttachOk.
fn attach_through_relay(
config: &std::path::Path,
relay: &Relay,
) -> (UdpSocket, BootstrapResponse, AttachOk) {
let config = load_server_config(Some(config.to_path_buf())).unwrap();
let secret = load_or_create_server_secret(&config).unwrap();
let bootstrap = build_bootstrap(
&config,
&secret,
"tester".to_string(),
"default".to_string(),
"read-write".to_string(),
(80, 24),
crypto::random_12(),
"127.0.0.1".to_string(),
)
.unwrap();
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(200)))
.unwrap();
let req = protocol::BootstrapAttachRequest {
bootstrap: bootstrap.clone(),
cols: 80,
rows: 24,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::BootstrapAttachRequest,
[0u8; 16],
1,
0,
&protocol::to_body(&req).unwrap(),
)
.unwrap();
// Retry the attach request a few times in case the relay drops it.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
socket.send_to(&packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
if let Ok(decoded) = protocol::decode(&buf[..n]) {
if decoded.header.kind == PacketKind::AttachOk {
let plain = protocol::decrypt_body(
&decoded,
&bootstrap.session_key,
SERVER_TO_CLIENT,
)
.unwrap();
let ok: AttachOk = protocol::from_body(&plain).unwrap();
return (socket, bootstrap, ok);
}
}
}
Err(_) => {}
}
if Instant::now() > deadline {
panic!("attach through relay timed out");
}
}
}
fn send_input(
socket: &UdpSocket,
relay: &Relay,
client_id: [u8; 16],
seq: u64,
ack: u64,
key: &[u8; 32],
text: &[u8],
) {
let input = Input {
bytes: text.to_vec(),
};
let packet = protocol::encode_encrypted(
PacketKind::Input,
client_id,
seq,
ack,
key,
CLIENT_TO_SERVER,
&protocol::to_body(&input).unwrap(),
)
.unwrap();
socket.send_to(&packet, relay.front_addr()).unwrap();
}
fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
socket.send_to(packet, relay.front_addr()).unwrap();
}
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
let packet = protocol::decode(&buf[..n]).ok()?;
match packet.header.kind {
PacketKind::Frame | PacketKind::ResumeOk => {
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
let frame: Frame = protocol::from_body(&plain).ok()?;
Some((packet.header, frame))
}
_ => None,
}
}
/// Collect terminal output text for up to `millis`, returning all decoded frame
/// bytes concatenated.
fn collect_text(socket: &UdpSocket, key: &[u8; 32], millis: u64) -> String {
let prev = socket.read_timeout().unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_millis(millis);
let mut text = String::new();
while Instant::now() < deadline {
if let Some((_h, frame)) = recv_frame(socket, key) {
text.push_str(&String::from_utf8_lossy(&frame.bytes));
}
}
socket.set_read_timeout(prev).unwrap();
text
}
/// Wait until terminal output containing `needle` is observed, retrying for up
/// to `millis`. Returns true if seen.
fn wait_for_text(socket: &UdpSocket, key: &[u8; 32], needle: &str, millis: u64) -> bool {
let deadline = Instant::now() + Duration::from_millis(millis);
let mut acc = String::new();
while Instant::now() < deadline {
acc.push_str(&collect_text(socket, key, 200));
if acc.contains(needle) {
return true;
}
}
acc.contains(needle)
}
#[test]
fn session_survives_packet_loss_and_reorder() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0x105_5u64 ^ 0x1111);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
// Introduce 40% loss in both directions and frequent duplication, plus
// reorder on the input flight. The server retransmits unacked frames and
// the client retransmits input, so the command must still land.
relay.set_drop_c2s(40);
relay.set_drop_s2c(40);
relay.set_dup(30);
let mut seq = 2u64;
let mut seen = false;
// Send the same logical command several times with monotonically rising
// sequence numbers (as a real client retransmitting would), interleaving a
// reorder toggle, until the output is observed despite the lossy link.
for attempt in 0..20 {
if attempt % 3 == 0 {
relay.arm_reorder();
}
send_input(
&socket,
&relay,
ok.client_id,
seq,
0,
&bootstrap.session_key,
b"printf DOSH_LOSSY_OK\\n\n",
);
seq += 1;
if wait_for_text(&socket, &bootstrap.session_key, "DOSH_LOSSY_OK", 400) {
seen = true;
break;
}
}
relay.clear_impairments();
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
seen,
"terminal output never arrived across a lossy/reordering link"
);
}
#[test]
fn duplicated_and_replayed_input_is_applied_at_most_once() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xD0D0u64);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
// Append a fixed token to a file once per *distinct* delivered input. We use
// `>>` so every time the server's PTY actually executes the command, a new
// line is appended. Replay protection must ensure the duplicate/replayed
// packet at the same sequence number is NOT re-applied.
let marker = dir.path().join("dup_marker");
let cmd = format!("printf x >> {}\n", marker.display());
// Build one encrypted Input packet at a fixed sequence and send it many
// times verbatim (a true replay: identical bytes, identical seq/nonce).
let input = Input {
bytes: cmd.into_bytes(),
};
let replayed = protocol::encode_encrypted(
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&input).unwrap(),
)
.unwrap();
// Also have the relay duplicate everything, to stack duplication on top of
// our explicit replays.
relay.set_dup(100);
for _ in 0..12 {
send_raw(&socket, &relay, &replayed);
thread::sleep(Duration::from_millis(40));
}
relay.set_dup(0);
// Give the PTY time to run and flush.
thread::sleep(Duration::from_millis(800));
// Drive a fence command so we know the PTY has processed at least up to here
// before we read the marker file.
send_input(
&socket,
&relay,
ok.client_id,
3,
0,
&bootstrap.session_key,
b"printf DUP_FENCE\\n\n",
);
let _ = wait_for_text(&socket, &bootstrap.session_key, "DUP_FENCE", 2000);
thread::sleep(Duration::from_millis(300));
let count = fs::read(&marker).map(|b| b.len()).unwrap_or(0);
drop(relay);
let _ = server.kill();
let _ = server.wait();
// The replayed/duplicated identical packet must apply at most once. If
// replay protection were broken we would see many 'x' bytes.
assert!(
count <= 1,
"replayed/duplicated input was applied {count} times (expected at most 1)"
);
}
#[test]
fn stale_packets_after_resume_are_ignored_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0x57A1u64);
let (old_socket, bootstrap, ok) = attach_through_relay(&config, &relay);
// Send an initial command on the original socket and confirm it lands.
send_input(
&old_socket,
&relay,
ok.client_id,
2,
0,
&bootstrap.session_key,
b"printf DOSH_STALE_BEFORE\\n\n",
);
assert!(
wait_for_text(
&old_socket,
&bootstrap.session_key,
"DOSH_STALE_BEFORE",
3000
),
"initial command did not land before resume"
);
// Simulate a reconnect from a new socket via a ResumeRequest (roaming),
// mirroring tests/integration_smoke.rs::resume_updates_udp_endpoint.
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
new_socket
.set_read_timeout(Some(Duration::from_millis(200)))
.unwrap();
let resume = ResumeRequest {
session: "default".to_string(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
let resume_packet = protocol::encode_encrypted(
PacketKind::ResumeRequest,
ok.client_id,
100,
0,
&bootstrap.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&resume).unwrap(),
)
.unwrap();
let mut resumed_seq = None;
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
new_socket
.send_to(&resume_packet, relay.front_addr())
.unwrap();
if let Some((_h, frame)) = recv_frame(&new_socket, &bootstrap.session_key) {
if frame.snapshot {
resumed_seq = Some(frame.output_seq);
break;
}
}
}
let resumed_seq = resumed_seq.expect("resume snapshot never arrived");
// Now replay a STALE packet from the OLD socket with a low sequence number
// (already-seen / out of the replay window). This must NOT terminate the
// session.
let stale = protocol::encode_encrypted(
PacketKind::Input,
ok.client_id,
2, // old, already-consumed sequence
0,
&bootstrap.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&Input {
bytes: b"printf DOSH_STALE_REPLAY\\n\n".to_vec(),
})
.unwrap(),
)
.unwrap();
for _ in 0..5 {
old_socket.send_to(&stale, relay.front_addr()).unwrap();
thread::sleep(Duration::from_millis(30));
}
// The session must remain alive on the resumed socket: a fresh command with
// a higher sequence still produces output.
send_input(
&new_socket,
&relay,
ok.client_id,
101,
resumed_seq,
&bootstrap.session_key,
b"printf DOSH_STALE_AFTER\\n\n",
);
let alive = wait_for_text(
&new_socket,
&bootstrap.session_key,
"DOSH_STALE_AFTER",
3000,
);
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
alive,
"session was killed by stale packets after reconnect (should be ignored, not fatal)"
);
}
#[test]
fn client_source_address_change_preserves_session() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xADD12u64);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
// Confirm the session works before the address change.
send_input(
&socket,
&relay,
ok.client_id,
2,
0,
&bootstrap.session_key,
b"printf DOSH_ADDR_BEFORE\\n\n",
);
assert!(
wait_for_text(&socket, &bootstrap.session_key, "DOSH_ADDR_BEFORE", 3000),
"command before source-address change did not land"
);
// Switch the client's source address as the server sees it by rebinding the
// relay's upstream socket (spec §11: "Connection migration must be accepted
// after any valid encrypted packet from a new source address").
let new_addr = relay.rebind_upstream();
assert_ne!(new_addr.port(), 0);
// The next valid encrypted packet now arrives from a new source address.
// The session must keep working without a re-handshake.
let mut migrated = false;
let mut seq = 3u64;
for _ in 0..12 {
send_input(
&socket,
&relay,
ok.client_id,
seq,
0,
&bootstrap.session_key,
b"printf DOSH_ADDR_AFTER\\n\n",
);
seq += 1;
if wait_for_text(&socket, &bootstrap.session_key, "DOSH_ADDR_AFTER", 500) {
migrated = true;
break;
}
}
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
migrated,
"session did not survive a client source-address change (connection migration)"
);
}
+311
View File
@@ -1246,3 +1246,314 @@ fn resume_updates_udp_endpoint_for_roaming() {
"expected output on resumed socket, got {text:?}"
);
}
#[test]
fn transport_rekey_round_trip_keeps_session_alive() {
use dosh::native::derive_rekey_session_key;
use dosh::protocol::Rekey;
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
// Rotate after a single packet so a rekey fires almost immediately.
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str("rekey_after_packets = 1\n");
fs::write(&config, raw).unwrap();
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
// Track the live transport key; it rotates when a Rekey arrives.
let mut current_key = bootstrap.session_key;
let mut current_key_id = protocol::session_key_id(&current_key);
let mut send_seq = 2u64;
// Produce some output so the server starts sending frames (and rekeys).
let input = Input {
bytes: b"printf DOSH_REKEY_ONE\\n\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
send_seq,
0,
&current_key,
&protocol::to_body(&input).unwrap(),
);
send_seq += 1;
// Drive the loop: decrypt frames under the live key, and when a Rekey lands,
// adopt the new epoch key and ack it. Confirm a post-rekey input still works.
let mut rekeyed = false;
let mut saw_post_rekey_output = false;
let mut buf = [0u8; 65535];
let deadline = std::time::Instant::now() + Duration::from_secs(8);
while std::time::Instant::now() < deadline {
let Ok((n, _)) = socket.recv_from(&mut buf) else {
continue;
};
let Ok(packet) = protocol::decode(&buf[..n]) else {
continue;
};
match packet.header.kind {
PacketKind::Rekey => {
let plain =
protocol::decrypt_body(&packet, &current_key, SERVER_TO_CLIENT).unwrap();
let rekey: Rekey = protocol::from_body(&plain).unwrap();
let new_key = derive_rekey_session_key(
&current_key,
&rekey.rekey_material,
&current_key_id,
rekey.epoch,
)
.unwrap();
assert_eq!(
protocol::session_key_id(&new_key),
rekey.new_session_key_id,
"client-derived rekey key id must match the server's"
);
current_key = new_key;
current_key_id = rekey.new_session_key_id;
// Ack under the NEW key.
send_encrypted(
&socket,
port,
PacketKind::RekeyAck,
ok.client_id,
send_seq,
0,
&current_key,
b"",
);
send_seq += 1;
rekeyed = true;
// Now send a fresh input under the new key.
let input = Input {
bytes: b"printf DOSH_REKEY_TWO\\n\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
send_seq,
0,
&current_key,
&protocol::to_body(&input).unwrap(),
);
send_seq += 1;
}
PacketKind::Frame | PacketKind::ResumeOk => {
if let Ok(plain) = protocol::decrypt_body(&packet, &current_key, SERVER_TO_CLIENT) {
if let Ok(frame) = protocol::from_body::<Frame>(&plain) {
let text = String::from_utf8_lossy(&frame.bytes);
if rekeyed && text.contains("DOSH_REKEY_TWO") {
saw_post_rekey_output = true;
break;
}
}
}
}
_ => {}
}
}
let _ = server.kill();
let _ = server.wait();
assert!(rekeyed, "server never initiated a rekey");
assert!(
saw_post_rekey_output,
"post-rekey input/output round trip failed under the new epoch key"
);
}
#[test]
fn input_from_new_source_address_migrates_connection() {
// Spec §11: the server must accept client source-address migration after ANY
// valid authenticated/encrypted packet from a new address, not just resume.
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
// A fresh socket = a new source address (new ephemeral port). The very first
// packet from it is an ordinary encrypted Input, not a ResumeRequest.
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
new_socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let input = Input {
bytes: b"printf DOSH_MIGRATE\\n\n".to_vec(),
};
let packet = protocol::encode_encrypted(
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&input).unwrap(),
)
.unwrap();
new_socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
// Output frames for this input must now be delivered to the NEW socket,
// proving the server migrated `endpoint` off the original address.
let text = collect_frame_text(&new_socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("DOSH_MIGRATE"),
"expected server output on the migrated socket, got {text:?}"
);
}
#[test]
fn native_auth_rate_limit_rejects_flood_before_crypto() {
use dosh::native::{NATIVE_PROTOCOL_VERSION, NativeClientHello};
use dosh::protocol::NativeClientHelloBody;
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
// Squeeze the rate limit down to 2/min so a short burst trips it.
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str("native_auth_rate_limit_per_minute = 2\n");
fs::write(&config, raw).unwrap();
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let make_hello = || {
let (_, client_public) = dosh::native::generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: "local".to_string(),
requested_user: user.clone(),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody { hello }).unwrap(),
)
.unwrap()
};
let mut reasons = Vec::new();
// Burst of hellos; with a 2/min budget the later ones must be rejected.
for _ in 0..6 {
socket
.send_to(&make_hello(), format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
if let Ok((n, _)) = socket.recv_from(&mut buf) {
let packet = protocol::decode(&buf[..n]).unwrap();
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
reasons.push(reject.reason);
}
}
}
let _ = server.kill();
let _ = server.wait();
assert!(
reasons.iter().any(|r| r.contains("rate limit")),
"expected a rate-limit reject within the burst, got {reasons:?}"
);
}
#[test]
fn native_hello_with_mismatched_protocol_version_gets_named_reject_not_hang() {
use dosh::native::{NATIVE_PROTOCOL_VERSION, NativeClientHello};
use dosh::protocol::{NativeClientHelloBody, VERSION_MISMATCH_REASON};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
// Same wire VERSION as the server (so the datagram decodes), but an
// incompatible native handshake protocol_version. This is the spec's
// plaintext negotiation point: the server must answer with a clear, named
// reject rather than letting the client time out.
let hello = NativeClientHello {
protocol_version: NATIVE_PROTOCOL_VERSION.wrapping_add(1),
client_random: crypto::random_32(),
client_ephemeral_public: [3u8; 32],
requested_host: "local".to_string(),
requested_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody { hello }).unwrap(),
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
// recv with the 2s read timeout above: a hang would surface as a recv error
// here instead of a reject, which is exactly what this test guards against.
let (n, _) = socket
.recv_from(&mut buf)
.expect("server must answer a version-mismatched hello, not hang");
let packet = protocol::decode(&buf[..n]).unwrap();
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert!(
reject.reason.contains(VERSION_MISMATCH_REASON),
"expected a named protocol-version-mismatch reject, got {:?}",
reject.reason
);
}
+415
View File
@@ -0,0 +1,415 @@
//! Parser robustness tests (Track B, spec milestone 5 / §16 "Fuzz packet parsing").
//!
//! These throw arbitrary/garbage bytes at every reachable public parser in the
//! `dosh` library and assert that NONE of them panic. A parser is allowed to
//! return `Ok` (if the bytes happened to be valid) or `Err`, but a panic on
//! untrusted input is a denial-of-service / robustness bug against a hostile
//! network attacker (threat model §5: "Active network attacker that can spoof
//! ... or modify packets").
//!
//! Determinism: a fixed-seed PRNG (`rand::rngs::StdRng`) is used so failures are
//! reproducible. No external dependencies beyond what is already in Cargo.toml.
use std::panic::{self, AssertUnwindSafe};
use dosh::auth::{
AttachTicketPlain, BootstrapResponse, SealedAttachTicket, decode_bootstrap, open_attach_ticket,
verify_attach_ticket,
};
use dosh::native::{
AuthorizedKey, HostPublicKey, KnownHost, NativeAuthOk, NativeClientHello, NativeServerHello,
NativeUserAuth, parse_authorized_keys, parse_host_public_key_line, parse_known_hosts,
parse_ssh_ed25519_public_blob, verify_known_host,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Header, Input,
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
NativeUserAuthBody, Packet, Resize, ResumeRequest, StreamClose, StreamData, StreamEof,
StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
TicketAttachEnvelope, TicketAttachOkEnvelope,
};
use rand::rngs::StdRng;
use rand::{Rng, RngCore, SeedableRng};
const ITERATIONS: usize = 4000;
/// Run `f` and convert a panic into a test failure with a descriptive message.
fn no_panic<F: FnOnce()>(label: &str, input: &[u8], f: F) {
let result = panic::catch_unwind(AssertUnwindSafe(f));
assert!(
result.is_ok(),
"parser `{label}` PANICKED on input ({} bytes): {:02x?}",
input.len(),
input,
);
}
/// Generate a variety of "interesting" byte buffers for a given iteration.
fn fuzz_bytes(rng: &mut StdRng) -> Vec<u8> {
let strategy = rng.gen_range(0..7u8);
match strategy {
0 => {
let len = rng.gen_range(0..1200);
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
1 => {
let len = rng.gen_range(0..16);
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
2 => {
let len = rng.gen_range(0..(protocol::HEADER_LEN + 64));
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
3 => vec![0u8; rng.gen_range(0..256)],
4 => vec![0xffu8; rng.gen_range(0..256)],
5 => {
// A valid-magic prefix followed by garbage to drive deeper paths.
let mut buf = Vec::new();
buf.extend_from_slice(protocol::MAGIC);
buf.push(protocol::VERSION);
let extra = rng.gen_range(0..256);
let mut tail = vec![0u8; extra];
rng.fill_bytes(&mut tail);
buf.extend_from_slice(&tail);
buf
}
_ => {
// Large length prefixes to provoke huge allocations / overflow in
// length fields (a classic deserialization hazard).
let mut buf = Vec::new();
buf.extend_from_slice(&u64::MAX.to_le_bytes());
let extra = rng.gen_range(0..64);
let mut tail = vec![0u8; extra];
rng.fill_bytes(&mut tail);
buf.extend_from_slice(&tail);
buf
}
}
}
/// Generate a possibly-valid UTF-8 string from random bytes (for text parsers).
fn fuzz_text(rng: &mut StdRng) -> String {
let len = rng.gen_range(0..256);
let mut s = String::new();
for _ in 0..len {
let pick = rng.gen_range(0..10u8);
let ch = match pick {
0 => ' ',
1 => '\n',
2 => '\t',
3 => '=',
4 => ',',
5 => '"',
6 => '/',
7 => rng.gen_range(b'a'..=b'z') as char,
8 => rng.gen_range(b'0'..=b'9') as char,
_ => char::from_u32(rng.gen_range(0..0x110000)).unwrap_or('?'),
};
s.push(ch);
}
s
}
#[test]
fn protocol_packet_decode_never_panics() {
let mut rng = StdRng::seed_from_u64(0xD05Au64);
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("protocol::decode", &input, || {
let _ = protocol::decode(&input);
});
no_panic("Header::parse", &input, || {
let _ = Header::parse(&input);
});
}
}
/// `from_body` deserializes a bincode body into each protocol/native struct.
/// On the wire this runs on attacker-controlled bytes, so it must never panic.
#[test]
fn protocol_from_body_never_panics() {
let mut rng = StdRng::seed_from_u64(0xBEEFu64);
macro_rules! body_target {
($input:expr, $ty:ty) => {{
let input = $input;
no_panic(concat!("from_body::<", stringify!($ty), ">"), input, || {
let _ = protocol::from_body::<$ty>(input);
});
}};
}
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
let input = input.as_slice();
// protocol.rs structs
body_target!(input, BootstrapAttachRequest);
body_target!(input, TicketAttachEnvelope);
body_target!(input, TicketAttachBody);
body_target!(input, TicketAttachOkEnvelope);
body_target!(input, AttachOk);
body_target!(input, AttachReject);
body_target!(input, ResumeRequest);
body_target!(input, Input);
body_target!(input, Resize);
body_target!(input, Frame);
body_target!(input, StreamOpen);
body_target!(input, StreamOpenOk);
body_target!(input, StreamOpenReject);
body_target!(input, StreamData);
body_target!(input, StreamWindowAdjust);
body_target!(input, StreamEof);
body_target!(input, StreamClose);
// native handshake wrapper bodies
body_target!(input, NativeClientHelloBody);
body_target!(input, NativeServerHelloBody);
body_target!(input, NativeUserAuthBody);
body_target!(input, NativeAuthOkBody);
body_target!(input, NativeAuthCheckOkBody);
// bare native handshake structs
body_target!(input, NativeClientHello);
body_target!(input, NativeServerHello);
body_target!(input, NativeUserAuth);
body_target!(input, NativeAuthOk);
body_target!(input, HostPublicKey);
// auth.rs structs (deserialized from untrusted material too)
body_target!(input, BootstrapResponse);
body_target!(input, SealedAttachTicket);
body_target!(input, AttachTicketPlain);
}
}
/// Full decode -> decrypt_body pipeline on garbage. decrypt should Err (not
/// panic) on bad ciphertext / wrong key id / truncated body.
#[test]
fn protocol_decode_then_decrypt_never_panics() {
let mut rng = StdRng::seed_from_u64(0x1234_5678u64);
let key = [7u8; 32];
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("decode+decrypt_body", &input, || {
if let Ok(packet) = protocol::decode(&input) {
let _ = protocol::decrypt_body(&packet, &key, protocol::CLIENT_TO_SERVER);
let _ = protocol::decrypt_body(&packet, &key, protocol::SERVER_TO_CLIENT);
}
});
}
}
/// Mutate a single byte of a valid encrypted packet; decode and decrypt must
/// not panic, and decryption of the mutated packet must fail (no double-apply).
#[test]
fn protocol_bit_flips_on_valid_packet_never_panic() {
let mut rng = StdRng::seed_from_u64(0x900Du64);
let key = [9u8; 32];
let conn_id = [3u8; 16];
for _ in 0..1000 {
let mut plaintext = vec![0u8; rng.gen_range(0..200)];
rng.fill_bytes(&mut plaintext);
let seq = rng.gen_range(1..u64::MAX);
let Ok(mut packet) = protocol::encode_encrypted(
protocol::PacketKind::Input,
conn_id,
seq,
0,
&key,
protocol::CLIENT_TO_SERVER,
&plaintext,
) else {
continue;
};
if packet.is_empty() {
continue;
}
let idx = rng.gen_range(0..packet.len());
packet[idx] ^= 1 << rng.gen_range(0..8);
no_panic("flip+decode+decrypt", &packet, || {
if let Ok(decoded) = protocol::decode(&packet) {
let _ = protocol::decrypt_body(&decoded, &key, protocol::CLIENT_TO_SERVER);
}
});
}
}
#[test]
fn ssh_ed25519_blob_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0x5511u64);
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("parse_ssh_ed25519_public_blob", &input, || {
let _ = parse_ssh_ed25519_public_blob(&input);
});
}
// Targeted: length prefixes that lie about the body length.
for bad_len in [0u32, 1, 31, 32, 33, u32::MAX, u32::MAX - 1] {
let mut buf = Vec::new();
buf.extend_from_slice(&bad_len.to_be_bytes());
buf.extend_from_slice(b"ssh-ed25519");
buf.extend_from_slice(&32u32.to_be_bytes());
buf.extend_from_slice(&[0u8; 16]);
no_panic("parse_ssh_ed25519_public_blob:lying-len", &buf, || {
let _ = parse_ssh_ed25519_public_blob(&buf);
});
}
}
#[test]
fn authorized_keys_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0xA011u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_authorized_keys", text.as_bytes(), || {
let _ = parse_authorized_keys(&text);
});
}
let crafted = [
"ssh-ed25519",
"ssh-ed25519 ",
"ssh-ed25519 not-base64!!!",
"from= ssh-ed25519 AAAA",
"from=\"unterminated ssh-ed25519 AAAA",
"command=\"x\\\" ssh-ed25519 AAAA",
"permitopen=,,, ssh-ed25519 AAAA",
"restrict,no-port-forwarding,from=\"127.0.0.1\" ssh-ed25519 AAAA comment",
"ssh-rsa AAAA",
"\u{0}\u{0}\u{0} ssh-ed25519 AAAA",
];
for line in crafted {
no_panic("parse_authorized_keys:crafted", line.as_bytes(), || {
let _ = parse_authorized_keys(line);
});
}
}
#[test]
fn known_hosts_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0xC051u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_known_hosts", text.as_bytes(), || {
let _ = parse_known_hosts(&text);
});
}
let crafted = [
"host",
"host dosh-ed25519",
"host dosh-ed25519 not-base64!!!",
"host wrong-algo AAAA",
"host dosh-ed25519 AAAA first-seen=notnum source=tofu",
"host dosh-ed25519 AAAA first-seen= source=",
"* dosh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
];
for line in crafted {
no_panic("parse_known_hosts:crafted", line.as_bytes(), || {
let _ = parse_known_hosts(line);
});
}
}
#[test]
fn host_public_key_line_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0x4002u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_host_public_key_line", text.as_bytes(), || {
let _ = parse_host_public_key_line(&text);
});
}
}
#[test]
fn decode_bootstrap_never_panics() {
let mut rng = StdRng::seed_from_u64(0xB007u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("decode_bootstrap", text.as_bytes(), || {
let _ = decode_bootstrap(&text);
});
// Also feed base64-shaped random for the decode path proper.
let raw = fuzz_bytes(&mut rng);
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&raw);
no_panic("decode_bootstrap:b64", b64.as_bytes(), || {
let _ = decode_bootstrap(&b64);
});
}
}
#[test]
fn attach_ticket_open_and_verify_never_panic() {
let mut rng = StdRng::seed_from_u64(0x7CE7u64);
let secret = [42u8; 32];
let psk = [11u8; 32];
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("open_attach_ticket", &input, || {
let _ = open_attach_ticket(&secret, &input);
});
no_panic("verify_attach_ticket", &input, || {
let _ = verify_attach_ticket(&secret, &input, &psk, "default", "read-write");
});
}
}
/// Throw garbage at the known-host verifier (file parse + host key compare).
#[test]
fn verify_known_host_with_garbage_keys_never_panics() {
let mut rng = StdRng::seed_from_u64(0x9090u64);
let dir = tempfile::tempdir().unwrap();
for _ in 0..500 {
let text = fuzz_text(&mut rng);
let path = dir.path().join("known_hosts");
std::fs::write(&path, &text).unwrap();
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let host = HostPublicKey {
algorithm: "dosh-ed25519".to_string(),
key: key_bytes,
};
let host_name = fuzz_text(&mut rng);
no_panic("verify_known_host", text.as_bytes(), || {
let _ = verify_known_host(&path, &host_name, &host);
});
}
}
/// Regression guard: valid inputs still parse, so the fuzz harness isn't
/// accidentally exercising a build where every path simply Errs.
#[test]
fn valid_inputs_still_parse() {
let key = [5u8; 32];
let blob = dosh::native::ssh_ed25519_public_blob(&key);
assert_eq!(parse_ssh_ed25519_public_blob(&blob).unwrap(), key);
let session_key = [1u8; 32];
let packet = protocol::encode_encrypted(
protocol::PacketKind::Input,
[2u8; 16],
1,
0,
&session_key,
protocol::CLIENT_TO_SERVER,
b"hello",
)
.unwrap();
let decoded: Packet = protocol::decode(&packet).unwrap();
let plain = protocol::decrypt_body(&decoded, &session_key, protocol::CLIENT_TO_SERVER).unwrap();
assert_eq!(plain, b"hello");
assert!(parse_authorized_keys("").unwrap().is_empty());
assert!(parse_known_hosts("# just a comment\n").unwrap().is_empty());
// Reference types only otherwise used in macro expansions / signatures.
let _ = std::mem::size_of::<AuthorizedKey>();
let _ = std::mem::size_of::<KnownHost>();
}
+133
View File
@@ -126,6 +126,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
);
}
#[test]
fn peek_foreign_wire_version_flags_only_version_skew() {
let key = crypto::random_32();
let mut packet = protocol::encode_encrypted(
PacketKind::Input,
crypto::random_16(),
1,
0,
&key,
CLIENT_TO_SERVER,
b"hi",
)
.unwrap();
// A correctly framed packet for this build is not "foreign".
assert_eq!(protocol::peek_foreign_wire_version(&packet), None);
// Bumping the wire version byte makes it undecodable but recognizable.
packet[4] = protocol::VERSION.wrapping_add(7);
assert_eq!(
protocol::peek_foreign_wire_version(&packet),
Some(protocol::VERSION.wrapping_add(7))
);
assert!(protocol::decode(&packet).is_err());
// Non-Dosh datagrams and runts are ignored.
assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None);
assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None);
}
#[test]
fn rekey_key_derivation_agrees_and_is_independent_per_epoch() {
use dosh::native::derive_rekey_session_key;
// The handshake/current key both peers already share.
let current_key = crypto::random_32();
let current_id = protocol::session_key_id(&current_key);
// Fresh server-generated material, delivered confidentially in the Rekey.
let material = crypto::random_32();
// Both peers derive identically from shared current key + shipped material.
let server_view = derive_rekey_session_key(&current_key, &material, &current_id, 1).unwrap();
let client_view = derive_rekey_session_key(&current_key, &material, &current_id, 1).unwrap();
assert_eq!(server_view, client_view);
// The rotated key must not equal the handshake/current key.
assert_ne!(server_view, current_key);
// A different epoch (or different material) yields a different key.
let next_epoch = derive_rekey_session_key(&current_key, &material, &current_id, 2).unwrap();
assert_ne!(server_view, next_epoch);
let other_material = crypto::random_32();
let other = derive_rekey_session_key(&current_key, &other_material, &current_id, 1).unwrap();
assert_ne!(server_view, other);
}
#[test]
fn rekey_round_trip_decrypts_old_and_new_epoch_packets() {
use dosh::native::derive_rekey_session_key;
let key_epoch0 = crypto::random_32();
let id0 = protocol::session_key_id(&key_epoch0);
let conn_id = crypto::random_16();
// Pre-rekey packet sealed under epoch-0 key.
let pre = protocol::encode_encrypted(
PacketKind::Frame,
conn_id,
5,
0,
&key_epoch0,
protocol::SERVER_TO_CLIENT,
b"before",
)
.unwrap();
// Rotate to epoch 1.
let material = crypto::random_32();
let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap();
let post = protocol::encode_encrypted(
PacketKind::Frame,
conn_id,
6,
0,
&key_epoch1,
protocol::SERVER_TO_CLIENT,
b"after",
)
.unwrap();
let pre = protocol::decode(&pre).unwrap();
let post = protocol::decode(&post).unwrap();
// Each epoch's key carries its own session_key_id; the receiver picks the
// right one and both decrypt correctly.
assert_eq!(pre.header.session_key_id, id0);
assert_eq!(
pre.header.session_key_id,
protocol::session_key_id(&key_epoch0)
);
assert_eq!(
post.header.session_key_id,
protocol::session_key_id(&key_epoch1)
);
assert_eq!(
protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(),
b"before"
);
assert_eq!(
protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(),
b"after"
);
// A stale-epoch packet under the wrong key is rejected via session_key_id
// BEFORE any AEAD work — ignorable, not a fatal decrypt error.
let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err();
assert!(err.to_string().contains("session key id"));
}
#[test]
fn check_native_protocol_version_names_the_mismatch() {
use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version};
check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap();
let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server")
.unwrap_err();
let message = err.to_string();
assert!(
message.contains(protocol::VERSION_MISMATCH_REASON),
"expected actionable upgrade message, got {message:?}"
);
assert!(
message.contains("server"),
"should name the wrong peer: {message:?}"
);
}
#[test]
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
let mut replay = ReplayWindow::new(8);