Native v1: connection fix, hardening, forwarding robustness, prediction, benchmarks, docs
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:
@@ -19,6 +19,38 @@ jobs:
|
|||||||
- name: Docker SSH benchmark gate
|
- name: Docker SSH benchmark gate
|
||||||
run: sh scripts/ci-docker-ssh-bench.sh
|
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:
|
remote-bench:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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:
|
build:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -12,14 +12,16 @@ fmt:
|
|||||||
install:
|
install:
|
||||||
sh packaging/install.sh
|
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:
|
bench-local:
|
||||||
cargo build
|
sh scripts/bench-local.sh
|
||||||
tmp="$$(mktemp -d)"; \
|
|
||||||
HOME="$$tmp" target/debug/dosh-server serve >/tmp/dosh-bench-server.log 2>&1 & \
|
# Same matrix, machine-readable JSON output (one object per metric with raw
|
||||||
pid="$$!"; \
|
# samples). Useful for publishing or regression tracking.
|
||||||
trap 'kill "$$pid" 2>/dev/null || true; rm -rf "$$tmp"' EXIT INT TERM; \
|
bench-local-json:
|
||||||
sleep 0.5; \
|
DOSH_BENCH_JSON=1 sh scripts/bench-local.sh
|
||||||
HOME="$$tmp" target/debug/dosh-bench --local-auth --server local --iterations 5
|
|
||||||
|
|
||||||
bench-docker-ssh:
|
bench-docker-ssh:
|
||||||
sh scripts/ci-docker-ssh-bench.sh
|
sh scripts/ci-docker-ssh-bench.sh
|
||||||
|
|||||||
@@ -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,
|
tickets, client ACKs, server retransmit bookkeeping, sliding replay protection,
|
||||||
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
|
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
|
||||||
script, Docker SSH benchmark gates, CI, and protocol/integration tests.
|
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.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
|
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
|
||||||
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
|
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
|
||||||
**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T`
|
**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
|
## 7. Security Model
|
||||||
|
|
||||||
SSH is the first trust root. dosh does not implement a competing public-key login
|
SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies
|
||||||
system in v0.
|
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
|
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
|
||||||
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
|
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,5 +1,30 @@
|
|||||||
# Dosh Native v1 Spec
|
# 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
|
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
|
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
|
||||||
keeping SSH as a compatibility and recovery fallback.
|
keeping SSH as a compatibility and recovery fallback.
|
||||||
|
|||||||
+84
-19
@@ -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.
|
comparison benchmark is reproducible outside the author's homelab.
|
||||||
|
|
||||||
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
|
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
|
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
|
||||||
implemented and verified, Dosh's public security claim remains SSH-bootstrap plus
|
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
|
||||||
encrypted Dosh transport.
|
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
|
## Objective Benchmarks
|
||||||
|
|
||||||
@@ -50,6 +58,10 @@ with ordinary SSH.
|
|||||||
| Feature | Mosh | Dosh now | Public status |
|
| Feature | Mosh | Dosh now | Public status |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| SSH-based first authentication | yes | yes | ready |
|
| 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 |
|
| Encrypted UDP terminal data | yes | yes | ready |
|
||||||
| Roaming by client address change | yes | yes | needs more hostile-network tests |
|
| Roaming by client address change | yes | yes | needs more hostile-network tests |
|
||||||
| Survive sleep or network loss | yes | yes | needs long-running soak 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 |
|
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
|
||||||
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
||||||
| SSH agent forwarding | no | no | planned as forwarding channel |
|
| SSH agent forwarding | no | no | planned as forwarding channel |
|
||||||
| Local TCP forwarding, `-L` | no | not implemented | planned |
|
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests |
|
||||||
| Remote TCP forwarding, `-R` | no | not implemented | planned |
|
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; needs hostile-network tests |
|
||||||
| Dynamic SOCKS forwarding, `-D` | no | not implemented | later |
|
| 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
|
## 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
|
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
|
||||||
OpenSSH config parser.
|
OpenSSH config parser.
|
||||||
|
|
||||||
## Forwarding Plan
|
## Forwarding (Implemented)
|
||||||
|
|
||||||
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
|
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
|
because that would remove Dosh's fast reconnect advantage and would break after
|
||||||
roaming. Dosh forwarding needs native encrypted channels over the Dosh transport.
|
roaming. Dosh forwarding therefore runs as native encrypted stream channels over the
|
||||||
|
Dosh transport, and is now implemented.
|
||||||
Minimum viable forwarding design:
|
|
||||||
|
|
||||||
| CLI | Meaning |
|
| CLI | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
|
| `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.
|
- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/
|
||||||
- Use per-stream flow control separate from terminal frame ordering.
|
`StreamEof`/`StreamClose` packet types.
|
||||||
- Never let bulk forwarding traffic delay terminal input/output packets.
|
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
|
||||||
- Bind forwarding permissions to the same SSH-authenticated user as the terminal.
|
frames, so bulk forwarding does not block PTY input/output.
|
||||||
- Add tests with dropped/reordered UDP packets.
|
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
|
||||||
|
`--local-auth` and requires the native auth path.
|
||||||
|
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
|
||||||
|
loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key
|
||||||
|
`no-port-forwarding` and `permitopen=`.
|
||||||
|
- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only
|
||||||
|
after listeners are ready).
|
||||||
|
|
||||||
This is a publishable differentiator once implemented, but it should not be claimed
|
Still open before claiming forwarding parity:
|
||||||
until it exists.
|
|
||||||
|
- 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
|
## Before Public Launch
|
||||||
|
|
||||||
@@ -119,3 +181,6 @@ until it exists.
|
|||||||
bracketed paste, and terminal cleanup.
|
bracketed paste, and terminal cleanup.
|
||||||
- Publish benchmark output with raw samples, not just averages.
|
- Publish benchmark output with raw samples, not just averages.
|
||||||
- Mark prediction as experimental until it has a real framebuffer model.
|
- 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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
/corpus
|
||||||
|
/artifacts
|
||||||
|
/coverage
|
||||||
|
Cargo.lock
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
Executable
+192
@@ -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
|
||||||
@@ -68,6 +68,12 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
|
|||||||
.decode(String::from_utf8_lossy(&raw).trim())
|
.decode(String::from_utf8_lossy(&raw).trim())
|
||||||
.context("decode server secret")?
|
.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];
|
let mut out = [0u8; 32];
|
||||||
out.copy_from_slice(&decoded[..32]);
|
out.copy_from_slice(&decoded[..32]);
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
|
|||||||
+367
-82
@@ -25,6 +25,15 @@ struct Args {
|
|||||||
iterations: usize,
|
iterations: usize,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
local_auth: bool,
|
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)]
|
#[arg(long)]
|
||||||
client: Option<PathBuf>,
|
client: Option<PathBuf>,
|
||||||
#[arg(long, default_value = "~/.local/bin/dosh-auth")]
|
#[arg(long, default_value = "~/.local/bin/dosh-auth")]
|
||||||
@@ -51,6 +60,12 @@ struct Args {
|
|||||||
mosh_server_command: String,
|
mosh_server_command: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
mosh_port: Option<String>,
|
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)]
|
#[arg(long)]
|
||||||
assert_ssh_plus_ms: Option<f64>,
|
assert_ssh_plus_ms: Option<f64>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -59,12 +74,45 @@ struct Args {
|
|||||||
assert_dosh_max_ms: Option<f64>,
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
let client = args.client.clone().unwrap_or_else(default_client_path);
|
let client = args.client.clone().unwrap_or_else(default_client_path);
|
||||||
let mut ssh_times = Vec::new();
|
if args.no_cache && args.warm_cache {
|
||||||
let mut dosh_times = Vec::new();
|
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
|
||||||
let mut mosh_times = Vec::new();
|
}
|
||||||
|
|
||||||
let generated_control_path = if args.controlmaster {
|
let generated_control_path = if args.controlmaster {
|
||||||
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
|
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
|
||||||
} else {
|
} else {
|
||||||
@@ -78,96 +126,112 @@ fn main() -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
if args.no_cache && args.warm_cache {
|
|
||||||
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
|
// Resolve which Dosh paths to benchmark. Explicit path flags select an
|
||||||
}
|
// explicit matrix; otherwise fall back to the legacy single-path behavior
|
||||||
let dosh_label = if args.warm_cache {
|
// so existing callers (CI docker scripts) keep working unchanged.
|
||||||
let _ = time_dosh_attach(&client, &args, control_path)?;
|
let explicit_paths = explicit_dosh_paths(&args);
|
||||||
"dosh_cached_attach_ms"
|
let dosh_paths = if explicit_paths.is_empty() {
|
||||||
|
vec![legacy_dosh_path(&args)]
|
||||||
} else {
|
} else {
|
||||||
"dosh_attach_ms"
|
explicit_paths
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
for _ in 0..args.iterations.max(1) {
|
||||||
if !args.local_auth && !args.skip_ssh_baseline {
|
|
||||||
let mut ssh = Command::new("ssh");
|
let mut ssh = Command::new("ssh");
|
||||||
add_ssh_options(&mut ssh, &args, control_path);
|
add_ssh_options(&mut ssh, &args, control_path);
|
||||||
ssh.arg(&args.server).arg("true");
|
ssh.arg(&args.server).arg("true");
|
||||||
ssh_times.push(time_command(&mut ssh)?);
|
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)?);
|
mosh_times.push(time_mosh_in_pty(&args)?);
|
||||||
}
|
}
|
||||||
|
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ssh_times.is_empty() {
|
/// True when none of the selected paths needs an SSH baseline comparison
|
||||||
println!(
|
/// (i.e. all are local-auth or cache fast paths driven without SSH).
|
||||||
"ssh_true_ms avg={:.2} samples={:?}",
|
fn dosh_only(paths: &[DoshPath]) -> bool {
|
||||||
avg_ms(&ssh_times),
|
paths.iter().all(|p| {
|
||||||
ssh_times
|
matches!(
|
||||||
);
|
p,
|
||||||
}
|
DoshPath::LocalAuth | DoshPath::CachedTicket | DoshPath::Resume
|
||||||
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");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn time_dosh_attach(
|
fn time_dosh_attach(
|
||||||
client: &PathBuf,
|
client: &PathBuf,
|
||||||
args: &Args,
|
args: &Args,
|
||||||
|
path: DoshPath,
|
||||||
control_path: Option<&PathBuf>,
|
control_path: Option<&PathBuf>,
|
||||||
|
warm: bool,
|
||||||
) -> Result<Duration> {
|
) -> Result<Duration> {
|
||||||
let mut cmd = Command::new(client);
|
let mut cmd = Command::new(client);
|
||||||
cmd.arg("--attach-only")
|
cmd.arg("--attach-only")
|
||||||
@@ -178,16 +242,55 @@ fn time_dosh_attach(
|
|||||||
if let Some(host) = &args.dosh_host {
|
if let Some(host) = &args.dosh_host {
|
||||||
cmd.arg("--dosh-host").arg(host);
|
cmd.arg("--dosh-host").arg(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
if args.local_auth {
|
||||||
cmd.arg("--local-auth").arg(&args.server);
|
cmd.arg("--local-auth");
|
||||||
} else {
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
cmd.arg("--ssh-port")
|
||||||
.arg(args.ssh_port.to_string())
|
.arg(args.ssh_port.to_string())
|
||||||
.arg("--ssh-auth-command")
|
.arg("--ssh-auth-command")
|
||||||
.arg(&args.ssh_auth_command);
|
.arg(&args.ssh_auth_command);
|
||||||
if args.no_cache {
|
|
||||||
cmd.arg("--no-cache");
|
|
||||||
}
|
|
||||||
if let Some(key) = &args.ssh_key {
|
if let Some(key) = &args.ssh_key {
|
||||||
cmd.arg("--ssh-key").arg(key);
|
cmd.arg("--ssh-key").arg(key);
|
||||||
}
|
}
|
||||||
@@ -197,9 +300,6 @@ fn time_dosh_attach(
|
|||||||
if let Some(control_path) = control_path {
|
if let Some(control_path) = control_path {
|
||||||
cmd.arg("--ssh-control-path").arg(control_path);
|
cmd.arg("--ssh-control-path").arg(control_path);
|
||||||
}
|
}
|
||||||
cmd.arg(&args.server);
|
|
||||||
}
|
|
||||||
time_command(&mut cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
|
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
|
||||||
@@ -381,9 +481,194 @@ fn time_command(cmd: &mut Command) -> Result<Duration> {
|
|||||||
Ok(start.elapsed())
|
Ok(start.elapsed())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn avg_ms(samples: &[Duration]) -> f64 {
|
/// Per-metric samples with summary statistics.
|
||||||
let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
|
struct MetricSamples {
|
||||||
total / samples.len() as f64
|
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 {
|
fn default_client_path() -> PathBuf {
|
||||||
|
|||||||
+721
-39
@@ -17,7 +17,7 @@ use dosh::native::{
|
|||||||
use dosh::protocol::{
|
use dosh::protocol::{
|
||||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||||
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
|
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,
|
StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
|
||||||
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||||
};
|
};
|
||||||
@@ -44,6 +44,20 @@ use tokio::sync::mpsc;
|
|||||||
|
|
||||||
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
|
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)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "dosh-client")]
|
#[command(name = "dosh-client")]
|
||||||
struct Args {
|
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 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 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
|
let auth_preference = args
|
||||||
.auth
|
.auth
|
||||||
.clone()
|
.clone()
|
||||||
@@ -2072,9 +2086,17 @@ async fn run_terminal(
|
|||||||
let mut last_packet_at = Instant::now();
|
let mut last_packet_at = Instant::now();
|
||||||
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
|
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
|
||||||
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
|
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 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_tx, mut forward_rx) = mpsc::channel::<ForwardEvent>(1024);
|
||||||
let _forward_keepalive = if local_forwards.is_empty() {
|
let _forward_keepalive = if local_forwards.is_empty() {
|
||||||
Some(forward_tx.clone())
|
Some(forward_tx.clone())
|
||||||
@@ -2131,6 +2153,9 @@ async fn run_terminal(
|
|||||||
None
|
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];
|
let mut recv_buf = vec![0u8; 65535];
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -2151,6 +2176,8 @@ async fn run_terminal(
|
|||||||
}
|
}
|
||||||
_ = resize_tick.tick() => {
|
_ = resize_tick.tick() => {
|
||||||
if let Ok((cols, rows)) = size()
|
if let Ok((cols, rows)) = size()
|
||||||
|
&& cols > 0
|
||||||
|
&& rows > 0
|
||||||
&& (cols, rows) != last_size
|
&& (cols, rows) != last_size
|
||||||
{
|
{
|
||||||
last_size = (cols, rows);
|
last_size = (cols, rows);
|
||||||
@@ -2169,7 +2196,16 @@ async fn run_terminal(
|
|||||||
}
|
}
|
||||||
match packet.header.kind {
|
match packet.header.kind {
|
||||||
PacketKind::Frame | PacketKind::ResumeOk => {
|
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(
|
if let Some(frame) = reconnect(
|
||||||
&socket,
|
&socket,
|
||||||
&mut cred,
|
&mut cred,
|
||||||
@@ -2209,6 +2245,48 @@ async fn run_terminal(
|
|||||||
PacketKind::Pong => {
|
PacketKind::Pong => {
|
||||||
last_packet_at = Instant::now();
|
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 => {
|
PacketKind::AttachReject => {
|
||||||
let reject: AttachReject = protocol::from_body(&packet.body)?;
|
let reject: AttachReject = protocol::from_body(&packet.body)?;
|
||||||
if reject.reason == "unknown client" {
|
if reject.reason == "unknown client" {
|
||||||
@@ -2337,18 +2415,21 @@ async fn run_terminal(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
last_packet_at = Instant::now();
|
last_packet_at = Instant::now();
|
||||||
|
let len = data.bytes.len();
|
||||||
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
|
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
|
||||||
let _ = writer.write_all(&data.bytes).await;
|
let _ = writer.write_all(&data.bytes).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(
|
send_stream_window_adjust(
|
||||||
&socket,
|
&socket,
|
||||||
addr,
|
addr,
|
||||||
&cred,
|
&cred,
|
||||||
&mut send_seq,
|
&mut send_seq,
|
||||||
data.stream_id,
|
data.stream_id,
|
||||||
data.bytes.len(),
|
len,
|
||||||
).await?;
|
).await?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
PacketKind::StreamWindowAdjust => {
|
PacketKind::StreamWindowAdjust => {
|
||||||
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -2894,85 +2975,522 @@ async fn send_stream_packet(
|
|||||||
Ok(())
|
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 {
|
struct Predictor {
|
||||||
|
mode: PredictMode,
|
||||||
enabled: bool,
|
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,
|
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 {
|
impl Predictor {
|
||||||
|
/// Construct with the default adaptive (experimental) display policy.
|
||||||
|
#[cfg(test)]
|
||||||
fn new(enabled: bool) -> Self {
|
fn new(enabled: bool) -> Self {
|
||||||
|
Self::with_mode(enabled, PredictMode::Experimental)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_mode(enabled: bool, mode: PredictMode) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled,
|
mode,
|
||||||
|
enabled: enabled && mode != PredictMode::Off,
|
||||||
alternate_screen: false,
|
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) {
|
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.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<()> {
|
fn observe_input(&mut self, bytes: &[u8]) -> Result<()> {
|
||||||
if !self.enabled || self.alternate_screen {
|
if !self.enabled || self.alternate_screen {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if !Self::predictable(bytes) {
|
// A large burst is almost certainly a paste, not interactive typing.
|
||||||
self.clear_pending()?;
|
// Predicting it would risk wrapping a long dim run across many lines, so
|
||||||
return Ok(());
|
// 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);
|
let mut i = 0;
|
||||||
self.draw_pending()
|
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]) {
|
fn observe_output(&mut self, bytes: &[u8]) {
|
||||||
if contains_bytes(bytes, b"\x1b[?1049h")
|
if contains_bytes(bytes, b"\x1b[?1049h")
|
||||||
|| contains_bytes(bytes, b"\x1b[?1047h")
|
|| contains_bytes(bytes, b"\x1b[?1047h")
|
||||||
|| contains_bytes(bytes, b"\x1b[?47h")
|
|| contains_bytes(bytes, b"\x1b[?47h")
|
||||||
{
|
{
|
||||||
self.alternate_screen = true;
|
self.alternate_screen = true;
|
||||||
self.pending.clear();
|
let _ = self.discard_all();
|
||||||
}
|
}
|
||||||
if contains_bytes(bytes, b"\x1b[?1049l")
|
if contains_bytes(bytes, b"\x1b[?1049l")
|
||||||
|| contains_bytes(bytes, b"\x1b[?1047l")
|
|| contains_bytes(bytes, b"\x1b[?1047l")
|
||||||
|| contains_bytes(bytes, b"\x1b[?47l")
|
|| contains_bytes(bytes, b"\x1b[?47l")
|
||||||
{
|
{
|
||||||
self.alternate_screen = false;
|
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 {
|
fn predictable(bytes: &[u8]) -> bool {
|
||||||
!bytes.is_empty() && bytes.iter().all(|byte| matches!(byte, 0x20..=0x7e))
|
!bytes.is_empty() && bytes.iter().all(|byte| matches!(byte, 0x20..=0x7e))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_pending(&self) -> Result<()> {
|
|
||||||
if self.pending.is_empty() {
|
|
||||||
return 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();
|
let mut stdout = std::io::stdout();
|
||||||
stdout.write_all(b"\x1b7\x1b[4m")?;
|
stdout.write_all(bytes)?;
|
||||||
stdout.write_all(&self.pending)?;
|
|
||||||
stdout.write_all(b"\x1b[24m\x1b8")?;
|
|
||||||
stdout.flush()?;
|
stdout.flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_pending(&mut self) -> Result<()> {
|
#[cfg(test)]
|
||||||
if self.pending.is_empty() {
|
fn emit_overlay(_bytes: &[u8]) -> Result<()> {
|
||||||
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(())
|
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);
|
||||||
|
}
|
||||||
|
match load_client_config(None) {
|
||||||
|
Ok(cfg) => PredictMode::parse(&cfg.predict_mode),
|
||||||
|
Err(_) => PredictMode::Experimental,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
@@ -3137,8 +3655,8 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DynamicForward, FrameBuffer, LocalForward, Predictor, RemoteForward, SshConfig,
|
DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor, RemoteForward,
|
||||||
auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward,
|
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,
|
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,
|
recv_response_until, requested_env, ssh_destination_host, ssh_username, ssh_with_user,
|
||||||
startup_command, toml_bare_key_or_quoted,
|
startup_command, toml_bare_key_or_quoted,
|
||||||
@@ -3514,6 +4032,170 @@ mod tests {
|
|||||||
assert!(!predictor.alternate_screen);
|
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]
|
#[test]
|
||||||
fn startup_command_joins_args_for_remote_shell() {
|
fn startup_command_joins_args_for_remote_shell() {
|
||||||
assert_eq!(startup_command(&[]), None);
|
assert_eq!(startup_command(&[]), None);
|
||||||
|
|||||||
+679
-111
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,14 @@ pub struct ServerConfig {
|
|||||||
pub authorized_keys: Vec<String>,
|
pub authorized_keys: Vec<String>,
|
||||||
#[serde(default = "default_native_auth_rate_limit_per_minute")]
|
#[serde(default = "default_native_auth_rate_limit_per_minute")]
|
||||||
pub native_auth_rate_limit_per_minute: u32,
|
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")]
|
#[serde(default = "default_true")]
|
||||||
pub allow_tcp_forwarding: bool,
|
pub allow_tcp_forwarding: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -61,6 +69,8 @@ impl Default for ServerConfig {
|
|||||||
host_key: default_host_key(),
|
host_key: default_host_key(),
|
||||||
authorized_keys: default_authorized_keys(),
|
authorized_keys: default_authorized_keys(),
|
||||||
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
|
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_tcp_forwarding: true,
|
||||||
allow_remote_forwarding: false,
|
allow_remote_forwarding: false,
|
||||||
allow_remote_non_loopback_bind: false,
|
allow_remote_non_loopback_bind: false,
|
||||||
@@ -84,6 +94,10 @@ pub struct ClientConfig {
|
|||||||
pub view_only: bool,
|
pub view_only: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub predict: bool,
|
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 cache_attach_tickets: bool,
|
||||||
pub credential_cache: String,
|
pub credential_cache: String,
|
||||||
#[serde(default = "default_auth_preference")]
|
#[serde(default = "default_auth_preference")]
|
||||||
@@ -141,6 +155,7 @@ impl Default for ClientConfig {
|
|||||||
reconnect_timeout_secs: 5,
|
reconnect_timeout_secs: 5,
|
||||||
view_only: false,
|
view_only: false,
|
||||||
predict: false,
|
predict: false,
|
||||||
|
predict_mode: default_predict_mode(),
|
||||||
cache_attach_tickets: true,
|
cache_attach_tickets: true,
|
||||||
credential_cache: "~/.local/share/dosh/credentials".to_string(),
|
credential_cache: "~/.local/share/dosh/credentials".to_string(),
|
||||||
auth_preference: default_auth_preference(),
|
auth_preference: default_auth_preference(),
|
||||||
@@ -175,6 +190,18 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
|
|||||||
30
|
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 {
|
fn default_auth_preference() -> String {
|
||||||
"native,ssh".to_string()
|
"native,ssh".to_string()
|
||||||
}
|
}
|
||||||
@@ -183,6 +210,10 @@ fn default_native_auth_timeout_ms() -> u64 {
|
|||||||
700
|
700
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_predict_mode() -> String {
|
||||||
|
"experimental".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_known_hosts() -> String {
|
fn default_known_hosts() -> String {
|
||||||
"~/.config/dosh/known_hosts".to_string()
|
"~/.config/dosh/known_hosts".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-11
@@ -15,7 +15,7 @@ use std::str::FromStr;
|
|||||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||||
|
|
||||||
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct HostPublicKey {
|
pub struct HostPublicKey {
|
||||||
@@ -248,17 +248,55 @@ pub fn sign_server_hello(
|
|||||||
Ok(())
|
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<()> {
|
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
|
||||||
anyhow::ensure!(
|
check_native_protocol_version(client.protocol_version, "client")?;
|
||||||
client.protocol_version == NATIVE_PROTOCOL_VERSION,
|
check_native_protocol_version(server.protocol_version, "server")?;
|
||||||
"unsupported native client protocol {}",
|
|
||||||
client.protocol_version
|
|
||||||
);
|
|
||||||
anyhow::ensure!(
|
|
||||||
server.protocol_version == NATIVE_PROTOCOL_VERSION,
|
|
||||||
"unsupported native server protocol {}",
|
|
||||||
server.protocol_version
|
|
||||||
);
|
|
||||||
let transcript = server_hello_transcript(client, server)?;
|
let transcript = server_hello_transcript(client, server)?;
|
||||||
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
|
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")
|
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> {
|
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
|
||||||
load_ed25519_identity_with_passphrase(path, None)
|
load_ed25519_identity_with_passphrase(path, None)
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-2
@@ -5,8 +5,14 @@ use anyhow::{Context, Result, bail};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const MAGIC: &[u8; 4] = b"DOSH";
|
pub const MAGIC: &[u8; 4] = b"DOSH";
|
||||||
pub const VERSION: u8 = 1;
|
pub const VERSION: u8 = 2;
|
||||||
pub const HEADER_LEN: usize = 58;
|
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;
|
const HEADER_AAD_LEN: usize = HEADER_LEN - 2;
|
||||||
pub const CLIENT_TO_SERVER: u32 = 1;
|
pub const CLIENT_TO_SERVER: u32 = 1;
|
||||||
pub const SERVER_TO_CLIENT: u32 = 2;
|
pub const SERVER_TO_CLIENT: u32 = 2;
|
||||||
@@ -39,6 +45,8 @@ pub enum PacketKind {
|
|||||||
StreamEof = 23,
|
StreamEof = 23,
|
||||||
StreamClose = 24,
|
StreamClose = 24,
|
||||||
NativeAuthCheckOk = 25,
|
NativeAuthCheckOk = 25,
|
||||||
|
Rekey = 26,
|
||||||
|
RekeyAck = 27,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<u8> for PacketKind {
|
impl TryFrom<u8> for PacketKind {
|
||||||
@@ -71,6 +79,8 @@ impl TryFrom<u8> for PacketKind {
|
|||||||
23 => Self::StreamEof,
|
23 => Self::StreamEof,
|
||||||
24 => Self::StreamClose,
|
24 => Self::StreamClose,
|
||||||
25 => Self::NativeAuthCheckOk,
|
25 => Self::NativeAuthCheckOk,
|
||||||
|
26 => Self::Rekey,
|
||||||
|
27 => Self::RekeyAck,
|
||||||
_ => bail!("unknown packet kind {value}"),
|
_ => bail!("unknown packet kind {value}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -110,7 +120,11 @@ impl Header {
|
|||||||
bail!("bad magic");
|
bail!("bad magic");
|
||||||
}
|
}
|
||||||
if input[4] != VERSION {
|
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 kind = PacketKind::try_from(input[5])?;
|
||||||
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Packet {
|
pub struct Packet {
|
||||||
pub header: Header,
|
pub header: Header,
|
||||||
@@ -369,6 +397,20 @@ pub struct StreamClose {
|
|||||||
pub stream_id: u64,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
|||||||
+23
-2
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::{Context, Result};
|
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::io::{Read, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -8,6 +8,7 @@ use tokio::sync::mpsc;
|
|||||||
|
|
||||||
pub struct PtyHandle {
|
pub struct PtyHandle {
|
||||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||||
|
child: Mutex<Box<dyn Child + Send + Sync>>,
|
||||||
_master: Box<dyn MasterPty + Send>,
|
_master: Box<dyn MasterPty + Send>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,25 @@ impl PtyHandle {
|
|||||||
})?;
|
})?;
|
||||||
Ok(())
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -68,7 +88,7 @@ pub fn spawn_pty_session(
|
|||||||
} else if let Some(parent) = Path::new(shell).parent() {
|
} else if let Some(parent) = Path::new(shell).parent() {
|
||||||
cmd.env("PWD", parent.as_os_str());
|
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);
|
drop(pair.slave);
|
||||||
|
|
||||||
let writer = pair.master.take_writer().context("take pty writer")?;
|
let writer = pair.master.take_writer().context("take pty writer")?;
|
||||||
@@ -110,6 +130,7 @@ pub fn spawn_pty_session(
|
|||||||
|
|
||||||
Ok(PtyHandle {
|
Ok(PtyHandle {
|
||||||
writer: Arc::new(Mutex::new(writer)),
|
writer: Arc::new(Mutex::new(writer)),
|
||||||
|
child: Mutex::new(child),
|
||||||
_master: pair.master,
|
_master: pair.master,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1246,3 +1246,314 @@ fn resume_updates_udp_endpoint_for_roaming() {
|
|||||||
"expected output on resumed socket, got {text:?}"
|
"expected output on resumed socket, got {text:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_rekey_round_trip_keeps_session_alive() {
|
||||||
|
use dosh::native::derive_rekey_session_key;
|
||||||
|
use dosh::protocol::Rekey;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
// Rotate after a single packet so a rekey fires almost immediately.
|
||||||
|
let mut raw = fs::read_to_string(&config).unwrap();
|
||||||
|
raw.push_str("rekey_after_packets = 1\n");
|
||||||
|
fs::write(&config, raw).unwrap();
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
|
||||||
|
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(300)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Track the live transport key; it rotates when a Rekey arrives.
|
||||||
|
let mut current_key = bootstrap.session_key;
|
||||||
|
let mut current_key_id = protocol::session_key_id(¤t_key);
|
||||||
|
let mut send_seq = 2u64;
|
||||||
|
|
||||||
|
// Produce some output so the server starts sending frames (and rekeys).
|
||||||
|
let input = Input {
|
||||||
|
bytes: b"printf DOSH_REKEY_ONE\\n\n".to_vec(),
|
||||||
|
};
|
||||||
|
send_encrypted(
|
||||||
|
&socket,
|
||||||
|
port,
|
||||||
|
PacketKind::Input,
|
||||||
|
ok.client_id,
|
||||||
|
send_seq,
|
||||||
|
0,
|
||||||
|
¤t_key,
|
||||||
|
&protocol::to_body(&input).unwrap(),
|
||||||
|
);
|
||||||
|
send_seq += 1;
|
||||||
|
|
||||||
|
// Drive the loop: decrypt frames under the live key, and when a Rekey lands,
|
||||||
|
// adopt the new epoch key and ack it. Confirm a post-rekey input still works.
|
||||||
|
let mut rekeyed = false;
|
||||||
|
let mut saw_post_rekey_output = false;
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(8);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
let Ok((n, _)) = socket.recv_from(&mut buf) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(packet) = protocol::decode(&buf[..n]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match packet.header.kind {
|
||||||
|
PacketKind::Rekey => {
|
||||||
|
let plain =
|
||||||
|
protocol::decrypt_body(&packet, ¤t_key, SERVER_TO_CLIENT).unwrap();
|
||||||
|
let rekey: Rekey = protocol::from_body(&plain).unwrap();
|
||||||
|
let new_key = derive_rekey_session_key(
|
||||||
|
¤t_key,
|
||||||
|
&rekey.rekey_material,
|
||||||
|
¤t_key_id,
|
||||||
|
rekey.epoch,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
protocol::session_key_id(&new_key),
|
||||||
|
rekey.new_session_key_id,
|
||||||
|
"client-derived rekey key id must match the server's"
|
||||||
|
);
|
||||||
|
current_key = new_key;
|
||||||
|
current_key_id = rekey.new_session_key_id;
|
||||||
|
// Ack under the NEW key.
|
||||||
|
send_encrypted(
|
||||||
|
&socket,
|
||||||
|
port,
|
||||||
|
PacketKind::RekeyAck,
|
||||||
|
ok.client_id,
|
||||||
|
send_seq,
|
||||||
|
0,
|
||||||
|
¤t_key,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
send_seq += 1;
|
||||||
|
rekeyed = true;
|
||||||
|
// Now send a fresh input under the new key.
|
||||||
|
let input = Input {
|
||||||
|
bytes: b"printf DOSH_REKEY_TWO\\n\n".to_vec(),
|
||||||
|
};
|
||||||
|
send_encrypted(
|
||||||
|
&socket,
|
||||||
|
port,
|
||||||
|
PacketKind::Input,
|
||||||
|
ok.client_id,
|
||||||
|
send_seq,
|
||||||
|
0,
|
||||||
|
¤t_key,
|
||||||
|
&protocol::to_body(&input).unwrap(),
|
||||||
|
);
|
||||||
|
send_seq += 1;
|
||||||
|
}
|
||||||
|
PacketKind::Frame | PacketKind::ResumeOk => {
|
||||||
|
if let Ok(plain) = protocol::decrypt_body(&packet, ¤t_key, SERVER_TO_CLIENT) {
|
||||||
|
if let Ok(frame) = protocol::from_body::<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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -126,6 +126,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peek_foreign_wire_version_flags_only_version_skew() {
|
||||||
|
let key = crypto::random_32();
|
||||||
|
let mut packet = protocol::encode_encrypted(
|
||||||
|
PacketKind::Input,
|
||||||
|
crypto::random_16(),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
&key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
b"hi",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// A correctly framed packet for this build is not "foreign".
|
||||||
|
assert_eq!(protocol::peek_foreign_wire_version(&packet), None);
|
||||||
|
// Bumping the wire version byte makes it undecodable but recognizable.
|
||||||
|
packet[4] = protocol::VERSION.wrapping_add(7);
|
||||||
|
assert_eq!(
|
||||||
|
protocol::peek_foreign_wire_version(&packet),
|
||||||
|
Some(protocol::VERSION.wrapping_add(7))
|
||||||
|
);
|
||||||
|
assert!(protocol::decode(&packet).is_err());
|
||||||
|
// Non-Dosh datagrams and runts are ignored.
|
||||||
|
assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None);
|
||||||
|
assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rekey_key_derivation_agrees_and_is_independent_per_epoch() {
|
||||||
|
use dosh::native::derive_rekey_session_key;
|
||||||
|
|
||||||
|
// The handshake/current key both peers already share.
|
||||||
|
let current_key = crypto::random_32();
|
||||||
|
let current_id = protocol::session_key_id(¤t_key);
|
||||||
|
// Fresh server-generated material, delivered confidentially in the Rekey.
|
||||||
|
let material = crypto::random_32();
|
||||||
|
|
||||||
|
// Both peers derive identically from shared current key + shipped material.
|
||||||
|
let server_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||||
|
let client_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||||
|
assert_eq!(server_view, client_view);
|
||||||
|
|
||||||
|
// The rotated key must not equal the handshake/current key.
|
||||||
|
assert_ne!(server_view, current_key);
|
||||||
|
|
||||||
|
// A different epoch (or different material) yields a different key.
|
||||||
|
let next_epoch = derive_rekey_session_key(¤t_key, &material, ¤t_id, 2).unwrap();
|
||||||
|
assert_ne!(server_view, next_epoch);
|
||||||
|
let other_material = crypto::random_32();
|
||||||
|
let other = derive_rekey_session_key(¤t_key, &other_material, ¤t_id, 1).unwrap();
|
||||||
|
assert_ne!(server_view, other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rekey_round_trip_decrypts_old_and_new_epoch_packets() {
|
||||||
|
use dosh::native::derive_rekey_session_key;
|
||||||
|
|
||||||
|
let key_epoch0 = crypto::random_32();
|
||||||
|
let id0 = protocol::session_key_id(&key_epoch0);
|
||||||
|
let conn_id = crypto::random_16();
|
||||||
|
|
||||||
|
// Pre-rekey packet sealed under epoch-0 key.
|
||||||
|
let pre = protocol::encode_encrypted(
|
||||||
|
PacketKind::Frame,
|
||||||
|
conn_id,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
&key_epoch0,
|
||||||
|
protocol::SERVER_TO_CLIENT,
|
||||||
|
b"before",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Rotate to epoch 1.
|
||||||
|
let material = crypto::random_32();
|
||||||
|
let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap();
|
||||||
|
let post = protocol::encode_encrypted(
|
||||||
|
PacketKind::Frame,
|
||||||
|
conn_id,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
&key_epoch1,
|
||||||
|
protocol::SERVER_TO_CLIENT,
|
||||||
|
b"after",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pre = protocol::decode(&pre).unwrap();
|
||||||
|
let post = protocol::decode(&post).unwrap();
|
||||||
|
|
||||||
|
// Each epoch's key carries its own session_key_id; the receiver picks the
|
||||||
|
// right one and both decrypt correctly.
|
||||||
|
assert_eq!(pre.header.session_key_id, id0);
|
||||||
|
assert_eq!(
|
||||||
|
pre.header.session_key_id,
|
||||||
|
protocol::session_key_id(&key_epoch0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
post.header.session_key_id,
|
||||||
|
protocol::session_key_id(&key_epoch1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||||
|
b"before"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||||
|
b"after"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A stale-epoch packet under the wrong key is rejected via session_key_id
|
||||||
|
// BEFORE any AEAD work — ignorable, not a fatal decrypt error.
|
||||||
|
let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("session key id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_native_protocol_version_names_the_mismatch() {
|
||||||
|
use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version};
|
||||||
|
check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap();
|
||||||
|
let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server")
|
||||||
|
.unwrap_err();
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains(protocol::VERSION_MISMATCH_REASON),
|
||||||
|
"expected actionable upgrade message, got {message:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
message.contains("server"),
|
||||||
|
"should name the wrong peer: {message:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
|
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
|
||||||
let mut replay = ReplayWindow::new(8);
|
let mut replay = ReplayWindow::new(8);
|
||||||
|
|||||||
Reference in New Issue
Block a user