Compare commits

...
39 Commits
Author SHA1 Message Date
DuProcess a202f97704 Quiet missing release lookup
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:20:55 -04:00
DuProcess f6ead86db4 Add setup selftest and diagnostics polish
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:19:29 -04:00
DuProcess 013d653f99 Quiet Gitea prebuilt fallback
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:10:15 -04:00
DuProcess 4e7e4cff10 Update release artifact checksums
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:09:25 -04:00
DuProcess fbad776441 Resolve Gitea release downloads
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:08:13 -04:00
DuProcess 90d9b583c0 Try prebuilt installers by default
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:03:11 -04:00
DuProcess f7c4ebaaf7 Add macOS release artifact evidence
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:00:56 -04:00
DuProcess 27419f4ca8 Fix Gitea release id parsing
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 17:51:36 -04:00
DuProcess ec2422bc3e Remove personal host aliases from defaults
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 17:50:26 -04:00
DuProcess d51cc248e7 Add release evidence and audit packet
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 10:45:55 -04:00
DuProcess b44ff8e773 Improve release and benchmark tooling 2026-06-20 10:37:26 -04:00
DuProcess 7884ea2796 Add prebuilt update path 2026-06-19 16:40:58 -04:00
DuProcess 774da7371e Chunk terminal output below MTU 2026-06-19 16:30:51 -04:00
DuProcess 41cdb0f54f Add optional command extensions 2026-06-19 16:17:38 -04:00
DuProcess 90e53f4b68 Add reliable stream retransmission 2026-06-19 16:00:51 -04:00
DuProcess d0d6f59cdf Add launch hardening gates 2026-06-18 09:45:27 -04:00
DuProcess 25d9a6aefa Expand native auth and benchmark gates 2026-06-18 09:29:06 -04:00
DuProcessandClaude Opus 4.8 2835da76b0 Persist only named/prewarmed sessions, not implicit ones
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
ci / test (push) Has been cancelled
Implicit sessions (dosh host with no --session) get a random
term-<millis>-<pid> name you can never reattach to, so persisting them just
left unreattachable holder processes lingering across restarts. The server
now only uses a persistent holder for prewarmed or explicitly-named
sessions; implicit ones use the in-process shell that dies on restart and
reaps on disconnect, as before. Adds a shared
protocol::{generate,is}_implicit_session_name as the single source of truth
for the name shape, used by both client (generate) and server (detect).

Server-only behavior change; wire VERSION unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:46:10 -04:00
DuProcessandClaude Opus 4.8 eec8ef0a02 Fall back to xterm-256color when the client TERM isn't on the server
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
dosh propagates the client's TERM, but a server lacking that terminal's
terminfo (e.g. xterm-ghostty, xterm-kitty) made tmux/vim fail with
"missing or unsuitable terminal". build_shell_command now keeps the
requested TERM only when its terminfo exists on this host, otherwise falls
back to xterm-256color so remote apps always work. Affects both the
in-process and holder-spawned shells.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:03:06 -04:00
DuProcessandClaude Opus 4.8 41256b66b7 persist_sessions: default off (opt-in until stress-tested)
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
Restart-survivable sessions change the core model (per-session holder
processes + SCM_RIGHTS fd passing). Keep them OFF by default so deploying
the build can't flip the daily driver's session model unproven; enable with
persist_sessions = true after stress-testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:23:48 -04:00
DuProcess 14b7e75025 Merge persistence: server-restart-survivable sessions (holder + SCM_RIGHTS fd passing) 2026-06-14 14:21:17 -04:00
DuProcessandClaude Opus 4.8 8b1af51bc6 Persist sessions across server restarts via per-session holders
Make dosh terminal sessions survive a dosh-server restart
(crash/upgrade/systemctl restart): a reattaching client lands on the
SAME shell with screen state intact, instead of a fresh one.

Design: when persist_sessions is on (new config, default true) each
terminal session's shell runs in a small detached per-session holder
process (the dosh-server binary re-exec'd as `dosh-server hold`). The
holder double-forks + setsid into its own session, opens the PTY, spawns
the shell as ITS child, and serves the PTY master fd over a per-session
Unix socket via SCM_RIGHTS. The server adopts that fd to build a PtyHandle
that DETACHES (does not kill the shell) on drop, so a server exit leaves
holder + shell alive. On startup the server scans the runtime dir under
sessions_dir/run, reconnects to each live holder, receives the master fd
again, restores the persisted vt100 screen, and rebuilds the Session.

Screen/scrollback (server-memory only) is mirrored to disk atomically:
byte-throttled on the output hot path plus a 2s periodic flush, and
restored on re-adoption so reattach repaints. Truly-abandoned persistent
sessions are still reaped per the existing grace logic by asking the
holder to shut down. Anything in the persistent path failing degrades
gracefully to the original in-process shell.

PtyHandle gains an Owned (kill-on-drop, unchanged default) vs Adopted
(detach-on-drop) backing; resize on an adopted master uses TIOCSWINSZ.

No wire-format change, so protocol::VERSION stays 3. New deps: libc
(direct). New config: persist_sessions (default true); existing
integration tests pin it false to keep exercising the non-persistent
path unchanged.

Adds tests/integration_smoke.rs::session_survives_server_restart_same_
shell_and_screen: attaches, sets `cd /tmp; export MARK=...`, paints a
screen marker, KILLs the server, restarts it on the same config, reattaches
and asserts same shell pid + MARK + PWD + restored screen. Plus persist.rs
unit tests (name round-trip, screen save/load, dead-holder scan cleanup,
SCM_RIGHTS fd round-trip). cargo fmt --check and cargo test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:19:09 -04:00
DuProcessandClaude Opus 4.8 a88d912d2b Client: instant SIGWINCH-driven terminal resize (mosh-style)
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
The client only polled crossterm size() every 250ms, so window resizes
(esp. in VSCode/terminals that signal rather than expecting polling) lagged
or felt unresponsive. Add a SIGWINCH select branch that resizes the instant
the signal fires, matching mosh (which reacts to SIGWINCH + TIOCGWINSZ). Keep
the 250ms poll as a fallback. Client-only; wire VERSION unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:02:08 -04:00
DuProcess 683c3cd01d Merge Wave 2: disconnect status line, prediction tick-policy fix, SSH-agent forwarding (VERSION 3)
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-14 11:11:42 -04:00
DuProcess 9a52d65beb Add opt-in, security-gated SSH-agent forwarding
Item 3: SSH-agent forwarding, double-gated (client -A/forward_agent AND server
allow_agent_forwarding) and off by default on both ends.

Wire: new ForwardingKind::Agent (appended, existing bincode discriminants
unchanged). Bumped protocol VERSION 2 -> 3 so a pre-agent peer answers with a
clear version-mismatch reject instead of a deserialize error.

Client: -A / --forward-agent flag; requires local SSH_AUTH_SOCK. Requests an
Agent forwarding during native auth (agent forwarding requires native auth) and,
on a server-initiated StreamOpen carrying the reserved @dosh-agent sentinel,
splices the stream into the local agent unix socket (separate agent_writers map,
TCP forwarding paths untouched). Rejects agent StreamOpens unless it opted in, so
a server cannot reach the agent without consent.

Server: when the client opted in and allow_agent_forwarding is set, binds a
per-session proxy unix socket (dir 0700, socket 0600) before the shell spawns,
exports its path as the session's SSH_AUTH_SOCK, and tunnels each connection back
to the client over the agent sentinel stream. Applies only to freshly spawned
shells (documented). Socket cleaned up on auth/forward failure and when the
accept loop ends. SECURITY rationale documented in comments.

Tests: 2 end-to-end integration tests (full chain identities round-trip via a
PTY-hosted client + looping fake agent; and "no -A => no proxy socket"). Docs
updated (README forwarding + disconnect-status note, spec §12.1). fmt + full test
suite green (122 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:10:44 -04:00
DuProcess 6b2933eb05 Add non-destructive disconnect status line and tick-driven prediction policy
Item 1: mosh-style disconnect status line. When UDP packets stop arriving,
paint a single reverse-video cyan "[dosh] last contact Ns ago — reconnecting…"
on the terminal's bottom row using save/restore cursor (ESC7/ESC8) so the app
cursor never moves and full-screen TUIs are not corrupted. Cleared the instant a
packet resumes and refreshed on the status timer tick. Configurable via
client.toml `disconnect_status` (default on) and `DOSH_DISCONNECT_STATUS` env
override. State machine and rendered escape sequences are pure and unit-tested.

Item 2: re-evaluate the prediction display policy on the select-loop status tick
via `Predictor::refresh_policy`, so a latency spike flips speculation on (or off
on recovery) promptly instead of waiting for the next keystroke. Repaints only
when the on-screen result would change, so idle ticks are no-ops.

Tests: 8 new client unit tests (status state machine + overlay bytes, predictor
spike/recovery/idle). fmt + full test suite green. No wire-format change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:57:40 -04:00
DuProcessandClaude Opus 4.8 8da98c45e7 Native v1: connection fix, hardening, forwarding robustness, prediction, benchmarks, docs
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
Integrates the stability fixes plus 5 parallel work tracks:
- Connection/panic/leak fixes + audit hardening (earlier on this branch)
- Track A: protocol VERSION 2 w/ clear mismatch reject, rekey, connection
  migration, native-auth rate limiting, O(1) conn_id index
- Track B: hostile-network integration tests + parser fuzzing + CI fuzz job
- Track C: benchmark path matrix + safe local harness + docs/BENCHMARKS.md
- Track D: docs/THREAT_MODEL.md + readiness/verification status
- Track E: clean-room mosh-grade predictive echo
- llms.txt agent overview

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:49:33 -04:00
DuProcess 2c7654138b Merge track A: VERSION discipline, rekey, migration, rate limiting, O(1) index 2026-06-14 10:47:14 -04:00
DuProcessandClaude Opus 4.8 f9c1973c13 Harden native protocol: version discipline, rekey, migration, rate limit, O(1) client index
Track A protocol hardening for native-v1.

1. Protocol VERSION discipline (protocol.rs, native.rs, dosh-server.rs,
   dosh-client.rs): bump protocol::VERSION to 2 and NATIVE_PROTOCOL_VERSION to 2.
   Foreign wire-version datagrams now get a clear named "protocol version
   mismatch - upgrade dosh" reject from the server instead of a silent timeout
   (peek_foreign_wire_version + VERSION_MISMATCH_REASON). The native handshake's
   plaintext protocol_version is also checked server-side before any crypto via
   check_native_protocol_version, returning a typed ProtocolVersionMismatch.

2. Transport rekey (spec section 11/9): server rotates a client's traffic key
   after rekey_after_packets OR rekey_after_secs (config knobs). Rotated keys are
   derived independently of the handshake keys from the current key plus fresh
   server CSPRNG material shipped confidentially in an AEAD Rekey packet
   (derive_rekey_session_key). The previous epoch's key is retained briefly so
   in-flight pre-rekey packets still decrypt (matched by session_key_id), and
   stale-epoch packets are ignored, not fatal. Client handles Rekey/RekeyAck.

3. Connection migration (spec section 11): every authenticated, replay-accepted
   packet now migrates client.endpoint to the new source address (input, resize,
   ping, ack, resume, stream*, rekey-ack), not just resume. Ping now verifies its
   AEAD tag before acting so migration cannot be spoofed.

4. Native auth rate limiting: per-source-IP token bucket
   (native_auth_rate_limit_per_minute) enforced in handle_native_client_hello
   BEFORE any X25519/Ed25519 work; over-limit hellos get a non-crypto reject.

5. Speed: replaced O(sessions x clients) per-packet linear scans with an O(1)
   HashMap<conn_id, session_name> index in ServerState, kept in sync on every
   client insert/remove (attach handlers, detach, remove_client, cleanup reap,
   session-exit drain).

New config keys (ServerConfig, with defaults): rekey_after_packets = 100000,
rekey_after_secs = 3600.

Tests: version-mismatch reject (no hang), rate-limit flood reject, rekey
round-trip end-to-end + key-derivation unit tests, source-address migration,
client-index sync + cleanup purge. fmt and full test suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:45:44 -04:00
DuProcess 1306adb558 Merge track E: clean-room mosh-grade predictive echo 2026-06-14 10:42:57 -04:00
DuProcessandClaude Opus 4.8 1e5517dcf1 Add llms.txt agent-oriented overview
Self-contained orientation for AI agents: what dosh is, capabilities,
the bidirectional homelab forwarding patterns (-L/-R/-D with concrete
examples), fast-path order, architecture, security summary, config, and
how to drive dosh non-interactively. Links to the deeper docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:42:03 -04:00
DuProcessandClaude Opus 4.8 cdeba047bc Add mosh-grade predictive local echo
Replace the basic printable-only predictor with a clean-room speculative
local-echo engine modeled on the Mosh paper's prediction overlay design
(no GPL code copied or translated; original Rust written from the
algorithm).

Model:
- Per-line cell model with a local cursor; printable keystrokes echo
  immediately, dimmed (and underlined on a very slow link) via
  save/restore cursor so full-screen TUIs are never corrupted.
- Backspace and in-line Left/Right arrow prediction (CSI and SS3).
- Epoch grouping per keystroke burst; anything unmodelable (CR/LF,
  escapes, control bytes, wide chars, right margin, paste-sized bursts)
  ends the epoch safely rather than mispredicting.
- Frame-sequenced confirmation: an authoritative server frame supersedes
  and erases the overlay (dosh has no client-side emulator, so it cannot
  do Mosh's per-cell content match; frame arrival is the confirmation).
- Glitch detection: contradicting output (e.g. alternate-screen entry)
  discards the epoch.
- SRTT-based display policy with hysteresis and a latency-spike override;
  modes off / experimental(adaptive, default) / always via new
  client config `predict_mode` and `DOSH_PREDICT_MODE` env override.

Improvements over the prior predictor: in-line arrow motion, dim+flag
styling, exact-width erase, paste guard, and faster (frame-arrival)
confirmation. Prediction is display-only and never swallows input.

Adds unit tests: printable run, confirmation-clears, glitch-discards,
backspace, line-wrap budget, paste-skip, newline, inline arrows, and the
experimental SRTT threshold gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:41:42 -04:00
DuProcess c47ae98c68 Merge track C: benchmark path matrix + safe local harness + BENCHMARKS.md 2026-06-14 10:34:30 -04:00
DuProcessandClaude Opus 4.8 c48f2280a0 Add benchmark path matrix and safe local harness
Extend dosh-bench to benchmark the full attach path matrix and emit both raw
per-iteration samples and summary statistics (count/min/median/p95/mean/max):

- --cold-native:   native cold auth terminal-ready (dosh_cold_native_ms)
- --cached-ticket: cached attach-ticket fast path (dosh_cached_attach_ms)
- --resume:        UDP resume path (dosh_resume_ms; roaming-only, see docs)
- --local-auth:    self-contained local bootstrap, no SSH (dosh_local_attach_ms)
- default:         legacy SSH-bootstrap cold attach (dosh_attach_ms)

Existing ssh-bootstrap/local-auth/mosh modes and all assert gates are preserved;
legacy flags (--local-auth/--warm-cache/--no-cache) keep their prior behavior so
the CI docker scripts run unchanged. Add --json (machine-readable raw samples)
and --label. Add a table/JSON renderer and percentile-based summary stats.

Add scripts/bench-local.sh: a safe, self-contained harness that builds release,
spins up a throwaway dosh-server bound to 127.0.0.1 on a random free UDP port in
a temp HOME (never port 50000, never a systemd unit), runs the matrix, prints
results, sanity-checks native auth actually trusted the host, and cleans up.

Point `make bench-local` at the new harness and add `make bench-local-json`;
keep bench-docker-* targets intact.

Add docs/BENCHMARKS.md: how to run each benchmark, metric meanings, methodology
and caveats (resume is the roaming path, not cold reconnect; cite cached attach
as the core claim per PUBLIC_READINESS.md), plus a real loopback sample table
with machine/OS/sample-count and raw samples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:33:37 -04:00
DuProcess 6bded28d40 Merge track B: hostile-network tests + fuzzing + CI fuzz job 2026-06-14 10:32:41 -04:00
DuProcessandClaude Opus 4.8 9b0f09a8a8 Add hostile-network tests, parser fuzzing, and CI fuzz job
Track B (milestone 5 / §16 hardening):

- tests/hostile_network.rs: in-process UDP relay/shim between a test
  client and a real dosh-server that can drop, reorder, and duplicate
  datagrams, and rebind its upstream socket mid-session. Asserts:
  session survives loss/reorder, duplicated/replayed Input is applied at
  most once, stale packets after resume are ignored (not fatal), and a
  client source-address change preserves the session.
- tests/parser_robustness.rs: deterministic randomized tests throwing
  garbage at every reachable public parser (packet decode, from_body for
  all protocol/native structs, authorized_keys, known_hosts, host-key
  line, ssh-ed25519 blob, bootstrap, attach ticket); asserts none panic.
- fuzz/: standalone cargo-fuzz crate (own [workspace], non-default
  member) with libfuzzer-sys harnesses for packet decode, from_body,
  authorized_keys, known_hosts, handshake structs+verifiers, and attach
  tickets. README documents the run command.
- .github/workflows/ci.yml: add fuzz-smoke job (nightly + cargo-fuzz,
  short -max_total_time per target, tolerant if tooling unavailable);
  existing fmt/test/build/bench steps unchanged.

cargo fmt --check and cargo test are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:31:47 -04:00
DuProcess c37424eb45 Merge track D: threat model + readiness docs 2026-06-14 10:29:05 -04:00
DuProcessandClaude Opus 4.8 828206f757 Publish threat model and refresh native v1 docs
Add docs/THREAT_MODEL.md deriving a publishable threat model from
NATIVE_V1_SPEC.md sections 4-6: assets, in/out-of-scope attackers,
security properties vs SSH (including where Dosh aims to exceed it),
cryptographic building blocks as implemented, and an honest
accepted-residual-risks / known-gaps section.

Refresh docs/PUBLIC_READINESS.md: feature matrix now reflects native
auth, host-key trust, authorized_keys policy, doctor, and -L/-R/-D
forwarding; add a per-item "Native v1 verification checklist status"
table mapping spec section 16 to done/in-progress/pending from code.

Update README.md status with a native v1 section and honest claim
posture pointing to THREAT_MODEL.md. Annotate NATIVE_V1_SPEC.md with a
v1 status block summarizing milestone progress. Point SPEC.md security
model and header at native auth and the threat model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:27:40 -04:00
DuProcessandClaude Opus 4.8 2a6f28d529 Fix native-v1 connection break, server panic, and resource leaks
The native-v1 work changed the UDP wire format (HEADER_LEN 42->58) without a
version bump, so a current client silently timed out against the older
still-deployed server. The server just needed redeploying; alongside that,
fix the robustness bugs surfaced while restoring the connection:

- Server: clamp terminal size to >=1 at every PTY/parser resize site. A client
  reporting a 0x0 terminal made vt100::Parser::set_size panic ("attempt to
  subtract with overflow") and took down the whole single-threaded daemon.
- Client: treat size() == Ok((0,0)) like a missing size and fall back to 80x24
  instead of sending 0x0 to the server.
- Reap abandoned sessions and their shells. PtyHandle now retains the child and
  kills it on drop, and the cleanup task removes clientless, non-prewarmed
  sessions after the grace period. Previously the shell leaked forever (one
  zsh per abandoned session); prewarmed sessions still stay hot.
- Evict half-finished native handshakes (pending_native) after 30s so an
  unauthenticated ClientHello flood can't grow the map without bound.
- Drop the per-client, per-packet vt100 Screen clone on the output hot path and
  remove the write-only last_screen field.
- Guard load_or_create_server_secret against a <32-byte secret file (was an
  index-out-of-range panic at startup).
- Reconcile stream flow-control credit (client and server) when StreamData hits
  a missing writer, so a closed/unknown stream can't wedge the peer's window.

Adds a unit test for session reaping. cargo fmt + cargo test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:06:27 -04:00
46 changed files with 11493 additions and 773 deletions
+56
View File
@@ -3,6 +3,9 @@ name: ci
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs:
test:
@@ -19,6 +22,59 @@ jobs:
- name: Docker SSH benchmark gate
run: sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nightly toolchain
id: nightly
continue-on-error: true
uses: dtolnay/rust-toolchain@nightly
- name: Install cargo-fuzz
id: install
if: steps.nightly.outcome == 'success'
continue-on-error: true
run: cargo install cargo-fuzz --locked
- name: Run fuzz targets briefly
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: |
set -e
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
else
sh scripts/fuzz-run.sh 20
fi
- name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
package-release:
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- os: ubuntu-latest
name: linux-x86_64
- os: macos-14
name: macos-aarch64
- os: macos-13
name: macos-x86_64
- os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Package release
shell: bash
run: sh scripts/package-release.sh
- uses: actions/upload-artifact@v4
with:
name: dosh-${{ matrix.name }}
path: |
target/dosh-release/dosh-*
!target/dosh-release/stage/**
remote-bench:
runs-on: ubuntu-latest
env:
Generated
+6 -1
View File
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
@@ -435,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"base64",
@@ -448,11 +449,14 @@ dependencies = [
"ed25519-dalek",
"hkdf",
"hmac",
"libc",
"portable-pty",
"rand",
"rpassword",
"rsa",
"serde",
"sha2",
"signature",
"ssh-key",
"tempfile",
"tokio",
@@ -1498,6 +1502,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek",
"num-bigint-dig",
"p256",
"p384",
"p521",
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT"
@@ -16,12 +16,15 @@ dirs = "5.0"
ed25519-dalek = "2.1"
hkdf = "0.12"
hmac = "0.12"
libc = "0.2"
portable-pty = "0.8"
rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
signature = "2.2"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] }
tokio = { version = "1.41", features = ["full"] }
toml = "0.8"
vt100 = "0.15"
+27 -8
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-docker-ssh bench-docker-mosh
.PHONY: build test fmt install package-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local
build:
cargo build --release
@@ -12,17 +12,36 @@ fmt:
install:
sh packaging/install.sh
package-release:
sh scripts/package-release.sh
bench-report:
sh scripts/bench-report.sh
# Safe, self-contained local benchmark matrix (native cold auth, cached attach
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
# a temp HOME. Never touches the production server or UDP port 50000.
bench-local:
cargo build
tmp="$$(mktemp -d)"; \
HOME="$$tmp" target/debug/dosh-server serve >/tmp/dosh-bench-server.log 2>&1 & \
pid="$$!"; \
trap 'kill "$$pid" 2>/dev/null || true; rm -rf "$$tmp"' EXIT INT TERM; \
sleep 0.5; \
HOME="$$tmp" target/debug/dosh-bench --local-auth --server local --iterations 5
sh scripts/bench-local.sh
# Same matrix, machine-readable JSON output (one object per metric with raw
# samples). Useful for publishing or regression tracking.
bench-local-json:
DOSH_BENCH_JSON=1 sh scripts/bench-local.sh
bench-docker-ssh:
sh scripts/ci-docker-ssh-bench.sh
bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
sh scripts/fuzz-run.sh 20
# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN.
fuzz-deep:
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
soak-local:
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
+155 -21
View File
@@ -81,6 +81,9 @@ dosh-server
client table per session
encrypted UDP protocol
tiny SSH-invoked dosh-auth helper mode
persistent sessions (persist_sessions, currently opt-in): each shell runs in a
detached per-session holder process; the server adopts its PTY master fd via
SCM_RIGHTS and re-adopts it after a restart, so sessions survive crash/upgrade
dosh-client
terminal raw mode
@@ -107,41 +110,68 @@ Install the client on macOS:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- client
```
Update an installed client later:
```bash
dosh update
dosh update --check
dosh --version
```
`dosh update` first tries a release tarball named for the platform
(`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest
Gitea/GitHub release. Gitea's concrete tag download route is detected when
`/releases/latest/download/...` is not supported. If that asset is not published
yet, it falls back to the source build path. If the release also publishes
`<artifact>.sha256`, the installer verifies the archive before installing it.
Install the client on Windows PowerShell:
```powershell
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_SERVER="palav"; $env:DOSH_HOST="git.palav.dev"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
```
Attach:
```bash
dosh palav
dosh user@server.example.com
```
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want
to reattach to the same persistent terminal from multiple clients:
First-time setup for an existing SSH alias:
```bash
dosh --session work palav
dosh --session logs palav
dosh setup homelab
dosh selftest homelab
dosh homelab
```
`dosh setup <ssh-alias>` imports the OpenSSH alias into
`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`,
and prints next commands. `dosh selftest <host>` checks local terminal overlay
safety, forwarding syntax, and the remote doctor path.
Plain `dosh user@server.example.com` opens a fresh terminal session. Use named
sessions when you want to reattach to the same persistent terminal from multiple
clients:
```bash
dosh --session work user@server.example.com
dosh --session logs user@server.example.com
```
Press `Ctrl-]` to detach the current client while leaving the server session alive.
Typing `exit` in the remote shell closes that Dosh session and returns the client.
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
and attempts ticket-based reconnect. The old in-band status overlay was removed
because writing directly over the terminal could corrupt TUIs; a non-destructive
status surface is tracked as a public-readiness item.
and attempts ticket-based reconnect. While the link is silent it shows a single,
non-destructive status line on the bottom screen row (`[dosh] last contact Ns ago
— reconnecting…`), drawn with save/restore cursor so it never moves the app cursor
or corrupts a full-screen TUI, and cleared the instant packets resume. (The old
in-band overlay wrote over the active line and could corrupt TUIs; this draws only
the last row.) Disable it with `disconnect_status = false` in `client.toml` or
`DOSH_DISCONNECT_STATUS=0`.
If SSH and UDP use different public names, specify the UDP address:
@@ -149,13 +179,44 @@ If SSH and UDP use different public names, specify the UDP address:
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
```
Forwarding can use SSH-shaped flags directly or the forward-only wrapper:
```bash
dosh -N -L 8080:127.0.0.1:80 homelab
dosh forward homelab -L 8080:127.0.0.1:80 -D 1080
```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
in Dosh's host config:
```bash
dosh import-ssh palav homelab
dosh import-ssh homelab
dosh homelab
```
Optional command extensions are just config-side startup shortcuts; Dosh has no
compile-time dependency on the tools they run. For example, to make a separately
installed server-side `tm` dashboard easy to open:
```toml
# ~/.config/dosh/client.toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
Then `dosh homelab tm` sends `tm`, and `dosh homelab tm dosh` sends `tm 'dosh'`.
Remove that table to remove the integration. Hosts can override or opt out:
```toml
# ~/.config/dosh/hosts.toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Develop
@@ -190,9 +251,9 @@ Benchmark the ControlMaster-backed SSH bootstrap path:
target/release/dosh-bench --server user@host --controlmaster --iterations 3
```
Run the Docker OpenSSH benchmark gate used by CI. It checks both cold SSH bootstrap
and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident
`dosh-server`:
Run the Docker OpenSSH benchmark gate used by CI. It checks cold SSH bootstrap,
ControlMaster-backed SSH bootstrap, native cold auth after one-time `dosh trust`,
and cached attach against a containerized `sshd` plus resident `dosh-server`:
```bash
make bench-docker-ssh
@@ -204,12 +265,39 @@ Run the same Docker comparison with Mosh installed in the benchmark container:
make bench-docker-mosh
```
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly;
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature
parity yet.
That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`,
`dosh_cached_attach_ms`, and, for the Mosh target, `mosh_start_true_ms` under the
same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
fast path after the first authentication has issued an attach ticket. See
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
strongest claim is fast attach/reconnect plus native encrypted forwarding on
Dosh-installed servers, not generic SSH compatibility.
The latest local release evidence is in
`docs/RELEASE_EVIDENCE_2026-06-20.md`.
Generate a publishable Markdown benchmark report:
```bash
make bench-report
```
Set `DOSH_BENCH_SERVER`, `DOSH_BENCH_ITERS`, `DOSH_BENCH_ARGS`, and
`DOSH_BENCH_REPORT` to target a real host and choose the output path.
Run the explicit pre-launch soak and fuzz gates:
```bash
make soak-local # 30-minute sleep/roaming gate by default
make fuzz-deep # 5 minutes per fuzz target by default
```
Both are configurable for shorter local shakedowns:
```bash
DOSH_SOAK_SECONDS=30 make soak-local
DOSH_FUZZ_SECONDS=60 make fuzz-deep
```
The CI workflow includes an optional remote benchmark job. It runs when
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
@@ -221,6 +309,13 @@ Install release binaries and the user systemd service:
make install
```
Build release tarballs for upload to Gitea/GitHub releases:
```bash
make package-release
GITEA_TOKEN=... scripts/upload-gitea-release.sh v0.1.0 target/dosh-release/dosh-*
```
## Performance Rules
The stack is performance-driven, not fixed by taste. Rust is the default because the
@@ -255,7 +350,8 @@ Hot-path rules:
- Replacing SSH as the first public-key trust mechanism.
- Multi-user access control.
- Windows support in v0.
- Windows server/full parity in v0; the client installer supports prebuilt Windows
client artifacts when published.
- Full mosh compatibility.
- Perfect predictive local echo in the first MVP.
@@ -267,3 +363,41 @@ resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP atta
tickets, client ACKs, server retransmit bookkeeping, sliding replay protection,
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
script, Docker SSH benchmark gates, CI, and protocol/integration tests.
### Native v1
Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substantially
implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
servers:
- **Native UDP auth** with X25519 key exchange; transcript-bound Ed25519, ECDSA
P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files;
ChaCha20-Poly1305 transport; and `authorized_keys` policy enforcement (`from=`,
`no-port-forwarding`, `permitopen=`; unsupported options fail closed).
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so
bulk transfers do not lag the terminal.
- **Agent forwarding** (opt-in, security-gated): `-A` / `forward_agent` exports your
local `SSH_AUTH_SOCK` to a per-session proxy socket on the server (private dir,
mode 0600) and tunnels each connection back to your agent over a Dosh stream. Only
active on explicit client opt-in **and** server `allow_agent_forwarding = true`; it
applies to freshly spawned shells (not an already-running attached/prewarmed
session, the same constraint ssh/mosh have).
- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks.
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 externally audited yet**. Local 30-minute sleep/roaming soak,
fuzz-smoke, and Docker SSH/Mosh benchmark evidence is captured in
`docs/RELEASE_EVIDENCE_2026-06-20.md`. See `docs/THREAT_MODEL.md` for the
published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
for the v1 versioning policy, `docs/AUDIT_PACKET.md` for the external security
review handoff, and the "Native v1 verification checklist status" table in
`docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not claim generic
SSH compatibility; its defensible claim is fast encrypted native attach/reconnect
and forwarding on Dosh-installed servers with SSH bootstrap fallback.
+9 -3
View File
@@ -4,7 +4,8 @@
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T`
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md`
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` (substantially implemented; see its v1 status block)
**Threat model:** `docs/THREAT_MODEL.md`
---
@@ -157,8 +158,11 @@ owner per session is preferred. Cross-thread designs are allowed only if benchma
## 7. Security Model
SSH is the first trust root. dosh does not implement a competing public-key login
system in v0.
SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies
on SSH for first authentication. Native v1 (`docs/NATIVE_V1_SPEC.md`) adds an opt-in
native public-key login over the Dosh UDP transport that is tried before SSH, with
its assets, attackers, and residual risks documented in `docs/THREAT_MODEL.md`. SSH
remains the explicit fallback and the host-key trust bootstrap path.
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
@@ -464,6 +468,8 @@ dosh_port = 50000
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
```
+75
View File
@@ -0,0 +1,75 @@
# Dosh Native v1 Audit Packet
This is the handoff checklist for an external security review.
## Scope
Review the security properties of Dosh native v1:
- Native UDP authentication and key exchange
- Host key trust and known-host mismatch behavior
- Authorized key parsing and policy enforcement
- Transport encryption, replay protection, rekeying, resume, and stale packet handling
- Attach ticket sealing/opening
- TCP forwarding and agent forwarding authorization boundaries
- Parser robustness for untrusted wire/config data
Primary files:
- `src/native.rs`
- `src/protocol.rs`
- `src/auth.rs`
- `src/ssh_agent.rs`
- `src/bin/dosh-client.rs`
- `src/bin/dosh-server.rs`
- `src/config.rs`
- `tests/parser_robustness.rs`
- `tests/protocol_auth.rs`
- `tests/hostile_network.rs`
- `tests/integration_smoke.rs`
- `fuzz/fuzz_targets/*`
Primary docs:
- `docs/NATIVE_V1_SPEC.md`
- `docs/THREAT_MODEL.md`
- `docs/PROTOCOL_VERSIONING.md`
- `docs/PUBLIC_READINESS.md`
- `docs/RELEASE_EVIDENCE_2026-06-20.md`
## Required Reviewer Questions
1. Is the native handshake transcript complete enough to bind client/server keys,
requested session, mode, env, and forwarding permissions?
2. Are host-key trust transitions fail-closed, especially unknown vs mismatch vs
explicit TOFU?
3. Are authorized-key options parsed conservatively enough, and are unsupported
options rejected safely?
4. Can replayed, reordered, delayed, stale, or cross-epoch packets affect terminal
input or forwarded streams more than once?
5. Does key rotation preserve confidentiality without creating split-brain epoch
states?
6. Do attach tickets create any bearer-token replay or privilege-extension issue?
7. Can UDP endpoint migration be abused for hijack or reflection?
8. Are local/remote/dynamic TCP forwarding and agent forwarding gated correctly by
client opt-in, server config, and authorized-key options?
9. Are parser and packet-size limits sufficient against malicious inputs?
10. Are there any unsafe assumptions in process/session persistence or holder
adoption?
## Current Local Evidence
As of 2026-06-20:
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: passed 30-minute sleep/roaming soak
- `make fuzz-smoke`: passed all configured fuzz targets for 20s each
- `make bench-docker-mosh`: passed SSH, ControlMaster, native, cached, and Mosh
comparison gates
## Explicit Non-Claims Before Audit
- No claim of generic SSH protocol replacement.
- No claim of full Mosh compatibility.
- No claim that native v1 is externally audited.
- No claim that Windows server support is production-ready.
+203
View File
@@ -0,0 +1,203 @@
# Dosh Benchmarks
This document explains how to run the Dosh benchmarks, what each metric means,
and how to read the results honestly. Dosh's headline claim is *insane speed on
repeat attach/reconnect* — these benchmarks exist to make that claim measurable
and reproducible.
> **Read first:** `docs/PUBLIC_READINESS.md`. The defensible public claim is fast
> attach/reconnect, not full Mosh feature parity, and **not** cold startup. Cite
> `dosh_cached_attach_ms` for the core speed claim; cite cold metrics only to show
> the fallback path stays competitive with ordinary SSH.
## TL;DR
```bash
make bench-local # safe, self-contained matrix on a throwaway server
make bench-local-json # same, machine-readable JSON with raw samples
make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI
make bench-docker-mosh # same, with Mosh installed for a three-way comparison
make bench-report # Markdown report wrapper around dosh-bench
```
`make bench-local` never touches a running production server: it builds release
binaries, starts its own `dosh-server` bound to `127.0.0.1` on a random free UDP
port inside a temporary `HOME`, runs the matrix, prints raw samples plus summary
statistics, and tears everything down on exit. It will refuse to bind UDP port
`50000` (the default production port).
## The benchmark binary: `dosh-bench`
`dosh-bench` spawns the real `dosh-client` once per iteration and times the whole
process from launch to terminal-ready-and-detached. That wall-clock cost is the
apples-to-apples comparison against `ssh host true`: it is the startup price each
tool pays before any useful remote work begins.
Every run prints, per metric, a summary line (count, min, median, p95, mean, max
in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable
object per run (with the full `samples_ms` array) so published numbers can always
include raw data, as required by `docs/PUBLIC_READINESS.md`. Pass `--markdown` for a
publishable Markdown table, and `--output path/to/report.md` to write it to disk.
### Path matrix
`dosh-bench` can benchmark these Dosh attach paths. Without an explicit path flag
it keeps its legacy single-path behavior (driven by `--local-auth` / `--warm-cache`
/ `--no-cache`) so existing CI scripts keep working.
| Flag | Metric | What it measures |
| --- | --- | --- |
| `--cold-native` | `dosh_cold_native_ms` | Native cold auth: full `--auth native --no-cache` handshake to first frame and detach. The cold fallback when no cache exists. |
| `--cached-ticket` | `dosh_cached_attach_ms` | Cached attach-ticket fast path. Warms the cache once, then measures repeat attaches using cached UDP credentials/tickets. **This is the core speed claim.** |
| `--resume` | `dosh_resume_ms` | UDP resume of a session whose endpoint changes. See the caveat below — only meaningful when a live session is kept open out-of-band. |
| `--local-auth` | `dosh_local_attach_ms` | Self-contained local bootstrap; no SSH, no native handshake. Useful as a lower-bound sanity check on loopback. |
| *(default, no flag)* | `dosh_attach_ms` | Cold SSH bootstrap plus UDP attach (legacy single-path mode). |
Existing baseline/comparison metrics are unchanged:
| Metric | Meaning |
| --- | --- |
| `ssh_true_ms` | `ssh host true` with the same key/options. |
| `mosh_start_true_ms` | `mosh host -- true` bootstrap, run `true`, exit (timed inside a PTY). |
### Assertions / gates
| Flag | Gate |
| --- | --- |
| `--assert-ssh-plus-ms N` | The primary Dosh metric must be ≤ `ssh_true_ms` mean + `N` ms. |
| `--assert-mosh-minus-ms N` | The primary Dosh metric must be at least `N` ms faster than `mosh_start_true_ms`. |
| `--assert-dosh-max-ms N` | The primary Dosh metric mean must be ≤ `N` ms. |
The "primary Dosh metric" for assertions is, in priority order:
`dosh_cached_attach_ms``dosh_resume_ms``dosh_cold_native_ms`
`dosh_attach_ms``dosh_local_attach_ms`.
## What each benchmark does
### `make bench-local` (`scripts/bench-local.sh`)
Self-contained, no SSH, no Docker. It:
1. Builds release binaries (`DOSH_BENCH_PROFILE=debug` for the debug profile).
2. Picks a free UDP port (never `50000`) and a temp `HOME`.
3. Generates a throwaway client identity (`ssh-keygen`), authorizes it on the
server, and writes a throwaway server + client config (native auth,
trust-on-first-use, localhost UDP).
4. Starts `dosh-server serve` bound to `127.0.0.1` on that port.
5. Runs `dosh-bench --cold-native --cached-ticket`, then `--local-auth --no-cache`.
6. Sanity-checks that native cold auth actually trusted the host (so a silent
config-parse fallback can't make the benchmark measure the wrong path).
7. Kills the server and removes the temp `HOME` on exit.
Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1`,
`DOSH_BENCH_PROFILE=release|debug`.
### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`)
Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster SSH-bootstrap
`dosh_attach_ms`, one-time `dosh trust` followed by native-cold
`dosh_cold_native_ms`, `dosh_cached_attach_ms`, and optionally
`mosh_start_true_ms` against the same container, key, and loopback network path.
This is the gate CI enforces; it asserts cold SSH-bootstrap Dosh stays within
500 ms of SSH, native-cold Dosh beats SSH mean by default, and cached attach stays
under a small budget. See `README.md` "Develop" for the exact invocations.
## Methodology and caveats
- **Same machine, same network path.** `bench-local` runs everything on loopback,
so it isolates Dosh's own process/handshake/render overhead and removes network
RTT from the comparison. Real-world cached attach is approximately *network RTT
plus the local overhead these numbers measure*. Publish the machine, OS, CPU,
network path, and sample count alongside any numbers.
- **Wall-clock of the whole client process.** Each sample includes process spawn,
attach, first-frame render, and detach. That is deliberately the same thing
`ssh host true` pays, but it means a few ms is fixed process-startup cost, not
protocol cost.
- **Do not cite cold metrics as the headline.** `dosh_cold_native_ms` and
`dosh_attach_ms` still pay first-authentication cost. They exist to show the
fallback path stays competitive with ordinary SSH. Cite `dosh_cached_attach_ms`
for repeat attach/reconnect, per `docs/PUBLIC_READINESS.md`.
- **UDP resume is the roaming path, not cold reconnect.** Resume reconnects a
client that is *still attached* when its network endpoint changes (sleep/Wi-Fi
switch). The `--attach-only` benchmark model detaches after every iteration,
which removes the client on the server, so a fresh-process resume has nothing
live to resume and is not meaningful in `bench-local`. The cold fresh-process
reconnect fast path is the attach ticket (`dosh_cached_attach_ms`). Roaming
resume correctness is covered by the integration test
`resume_updates_udp_endpoint_for_roaming` in `tests/integration_smoke.rs`.
`dosh-bench --resume` remains available for scenarios that keep a live session
open out-of-band (for example remote soak tests).
- **These are not identical workloads.** SSH/Mosh/Dosh do different work at
startup. The comparison is still useful because it measures the startup tax each
tool charges before useful remote work begins.
## Sample results (loopback, self-contained)
Captured with `make bench-local` (`scripts/bench-local.sh 20`), all times in
milliseconds, 20 samples per metric.
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
- Toolchain: rustc 1.96.0, release profile
- Network path: loopback (`127.0.0.1`), throwaway server on a free UDP port
- Workload: `dosh-client --attach-only` to first frame and detach
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `dosh_cold_native_ms` | 20 | 8.32 | 8.77 | 9.34 | 8.82 | 9.52 |
| `dosh_cached_attach_ms` | 20 | 2.83 | 3.23 | 3.75 | 3.24 | 3.77 |
| `dosh_local_attach_ms` | 20 | 2.84 | 3.21 | 3.96 | 3.28 | 3.98 |
Raw samples (ms):
```
dosh_cold_native_ms:
8.82, 8.60, 8.69, 8.71, 9.33, 8.82, 8.90, 8.64, 8.39, 8.75, 8.79, 8.70, 8.64,
9.52, 8.72, 8.32, 9.06, 8.93, 8.96, 9.04
dosh_cached_attach_ms:
3.04, 3.06, 2.98, 2.91, 2.96, 2.95, 3.05, 3.66, 3.37, 2.88, 2.83, 3.16, 3.49,
3.30, 3.77, 3.32, 3.49, 3.75, 3.56, 3.33
dosh_local_attach_ms:
3.08, 3.96, 3.36, 3.30, 2.88, 2.99, 3.96, 3.98, 3.25, 2.84, 3.29, 3.59, 3.65,
3.47, 3.13, 2.92, 2.87, 2.91, 2.98, 3.18
```
Reading these numbers: cold native auth is ~9 ms because it pays the native
handshake; cached attach is ~3 ms because it reuses cached credentials and skips
the handshake entirely. On loopback there is no network RTT, so this is the local
overhead floor. Over a real link, expect cached attach ≈ this floor + one network
round trip — which is exactly the "near network RTT" target in the spec. There is
no SSH/Mosh baseline in this loopback table because those paths need an SSH server;
use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head.
## Sample results (Docker SSH/Mosh comparison)
Captured with `make bench-docker-mosh`, all times in milliseconds. This is the
same-container comparison gate: one generated key, one OpenSSH server, one
`dosh-server`, loopback-published TCP/UDP ports.
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `ssh_true_ms` (cold SSH baseline for native-cold gate) | 5 | 214.53 | 216.84 | 220.55 | 217.60 | 220.91 |
| `dosh_cold_native_ms` | 5 | 8.62 | 8.96 | 9.78 | 9.15 | 9.88 |
| `dosh_cached_attach_ms` | 10 | 8.41 | 8.83 | 9.52 | 8.90 | 9.53 |
| `mosh_start_true_ms` | 3 | 528.03 | 530.87 | 539.23 | 533.02 | 540.16 |
Raw samples (ms):
```
ssh_true_ms (native-cold gate):
216.84, 216.61, 214.53, 220.91, 219.09
dosh_cold_native_ms:
9.39, 8.88, 8.96, 8.62, 9.88
dosh_cached_attach_ms:
8.44, 8.41, 8.80, 8.48, 9.53, 9.51, 8.85, 8.69, 9.33, 8.96
mosh_start_true_ms:
540.16, 528.03, 530.87
```
+72 -3
View File
@@ -1,5 +1,36 @@
# Dosh Native v1 Spec
> **v1 status (annotation, not part of the spec text below).** Native v1 is
> substantially implemented and being stabilized; it is not yet fully verified.
> Milestone progress against section 15:
>
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
> `known_hosts`, and mismatch hard-fail are implemented.
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
> `AuthOk`, ssh-agent and OpenSSH identity-file auth for Ed25519, ECDSA P-256,
> and RSA-SHA2, plus `authorized_keys` verification, exist.
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates
> cover cached attach, SSH fallback, and native cold auth (Track C /
> `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`,
> `-f`, per-stream flow control, ordered stream offsets/ACKs, and stream-data
> retransmission exist. Terminal-priority/load, replay/reorder, and dropped
> server-to-client `StreamData` recovery regressions are covered.
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
> fail-closed protocol-version checks with a documented v1 policy, parser fuzz
> targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests,
> forwarding load/priority/replay/loss tests, and independent persistent session
> restart tests exist. Published long-soak evidence is not yet complete. The threat
> model is published (`docs/THREAT_MODEL.md`).
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing.
>
> The section 16 verification checklist is **not yet fully green** — see the
> item-by-item status table in `docs/PUBLIC_READINESS.md` and the residual-risk list in
> `docs/THREAT_MODEL.md`. The section 17 public-claim gate is therefore **not met**.
Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
keeping SSH as a compatibility and recovery fallback.
@@ -20,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host
dosh --session work host
dosh --view-only --session work host
dosh host tm
```
Compatibility expectations:
@@ -76,6 +108,8 @@ Must work in v1:
- `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility.
- Optional command extensions such as `dosh host tm` for companion tools that are
installed separately from Dosh.
Should work in v1 if it does not compromise the transport schedule:
@@ -98,8 +132,14 @@ Native v1 must be boring under real use:
- A closed laptop must not kill the remote session.
- A client crash must not kill the remote session.
- Server restart may drop live PTYs in v1 unless session persistence is implemented,
but the client must fail clearly and reconnect cleanly afterward.
- A server restart must not kill the remote session when `persist_sessions` is on
(currently opt-in until stress-tested): each session's shell runs in a detached
per-session *holder*
process whose PTY master fd the server passes back to itself via SCM_RIGHTS, so
the shell + scrollback survive a server crash/upgrade/`systemctl restart` and a
reattaching client lands on the same shell with its screen restored. With
`persist_sessions = false` the old behavior applies: live PTYs drop on restart,
but the client must still fail clearly and reconnect cleanly afterward.
- Decrypt failures from stale packets must be ignored or trigger reconnect, never
terminate the terminal by themselves.
- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate
@@ -419,6 +459,7 @@ CLI:
dosh -L [bind_host:]listen_port:target_host:target_port host
dosh -R [bind_host:]listen_port:target_host:target_port host
dosh -D [bind_host:]listen_port host
dosh -A host # forward the local ssh-agent (opt-in, server-gated)
```
Stream packet types:
@@ -435,6 +476,12 @@ Forwarding rules:
- Terminal traffic has priority over stream bulk data.
- Each stream has independent flow control.
- `StreamData` carries a per-stream byte offset and is delivered to TCP in order.
- `StreamWindowAdjust` carries a cumulative received byte offset; peers free
retransmit buffers and replenish send credit only for acknowledged contiguous
bytes.
- Unacknowledged stream bytes are retransmitted as newly encrypted transport packets
with fresh packet sequence numbers/nonces.
- Backpressure must not block PTY input or output.
- Server enforces `no-port-forwarding` and `permitopen=`.
- Remote listeners bind to loopback by default.
@@ -448,6 +495,24 @@ Forwarding rules:
- Stream packet scheduling must enforce terminal priority; a large port-forward copy
cannot make shell keystrokes lag.
### 12.1 SSH-Agent Forwarding (opt-in, security-gated)
- Activated only on explicit client opt-in (`-A` / `forward_agent = true`) **and**
server `allow_agent_forwarding = true`. Off by default on both ends.
- The client requests a `ForwardingKind::Agent` entry during auth. If the server
policy allows it, the server binds a per-session proxy unix socket in a
process-private directory (dir 0700, socket 0600) and exports its path as the
spawned shell's `SSH_AUTH_SOCK`.
- Each connection from a remote process to that proxy socket is tunneled to the
client over a server-initiated `StreamOpen` carrying the reserved target sentinel
`@dosh-agent` (port 0). The client splices it into its local `SSH_AUTH_SOCK`
(a unix socket, not a TCP target). Data/window/close reuse the existing stream
packets unchanged.
- Only applies to a freshly spawned shell; an already-running attached/prewarmed
session keeps its existing environment (same constraint as ssh/mosh).
- SECURITY: forwarding exposes the local agent to the remote host for the session's
lifetime, which is why it is double-gated and never the default.
## 13. Diagnostics And Operations
Native v1 must include operations commands:
@@ -494,6 +559,10 @@ forward_agent = false
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
set_env = {}
forwardings = []
[extensions.tm]
command = "tm {args}"
description = "Open an optional server-side tmux dashboard"
```
Server:
@@ -544,7 +613,7 @@ Milestone 5: hardening
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
- Add hostile-network integration tests.
- Add external review checklist before public security claims.
- Keep a public hardening checklist and threat model before public security claims.
Milestone 6: workflow parity
+40
View File
@@ -0,0 +1,40 @@
# Dosh Protocol Versioning
Dosh v1 uses a deliberately simple compatibility policy: **single-version,
fail-closed, explicit error**.
The wire header carries `protocol::VERSION`. The native handshake carries
`native::NATIVE_PROTOCOL_VERSION`. A peer that speaks any other version is rejected
before application data is accepted:
- foreign wire `VERSION` packets get an `AttachReject` with
`protocol version mismatch - upgrade dosh`;
- foreign native handshake `protocol_version` values get the same named upgrade
error with local/remote versions;
- there is no silent downgrade, compatibility fallback, or best-effort decoding.
## When To Bump
Bump `protocol::VERSION` when a change affects packet framing, packet kind meaning,
serialized protocol structs carried outside native handshake negotiation, or anything
an older peer could misparse.
Bump `native::NATIVE_PROTOCOL_VERSION` when a change affects native handshake
transcripts, native auth semantics, algorithm negotiation, attach tickets, or any
field that is signed or key-derived by native auth.
If both layers are affected, bump both.
## Compatibility Window
Native v1 supports exactly the current version. That keeps the implementation small
and makes security review tractable. Multi-version negotiation can be added later
only with an explicit downgrade-resistance design:
- negotiated version must be transcript-bound;
- the selected version must be visible in diagnostics;
- tests must prove an active attacker cannot force an older mutually supported
version;
- unsupported peers must still get the same named upgrade error.
Until that exists, the public policy is: upgrade both sides together.
+132 -33
View File
@@ -1,13 +1,21 @@
# Dosh Public Readiness
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
claim full Mosh replacement status until the feature matrix below is green and the
comparison benchmark is reproducible outside the author's homelab.
Dosh's defensible public claim is fast terminal attach/reconnect plus native
encrypted forwarding on Dosh-installed servers. It should not claim generic SSH
compatibility unless it implements the SSH protocol, and public benchmark claims
must be reproducible outside the author's homelab.
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`. Until that spec is
implemented and verified, Dosh's public security claim remains SSH-bootstrap plus
encrypted Dosh transport.
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
`dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
feature matrix and the verification-checklist status table below). It is **not yet
fully verified**: long sleep/roaming soak and deeper fuzzing results are still open
launch evidence. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16)
is green, Dosh's defensible public security claim remains fast encrypted native
attach/reconnect and forwarding with SSH bootstrap fallback, not generic SSH
compatibility.
## Objective Benchmarks
@@ -50,25 +58,31 @@ with ordinary SSH.
| Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH identity files | implemented; pending full verification |
| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented |
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented |
| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests |
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
| Roaming by client address change | yes | yes | implemented; hostile-network covered, 30-minute soak gate available |
| Survive sleep or network loss | yes | yes | implemented; run `make soak-local` before public launch |
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes | ready |
| Multiple clients on one session | no | yes | needs conflict-policy docs |
| Named persistent sessions | no built-in shared session model | yes, plus opt-in server-restart holders | implemented; holder mode needs stress-testing |
| Multiple clients on one session | no | yes | implemented; needs conflict-policy docs |
| View-only clients | no | yes | ready |
| Full-screen TUI correctness | yes | improving | must stay green before public push |
| Full-screen TUI correctness | yes | yes, scripted transport coverage for control sequences | implemented; needs broader app matrix |
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
| Non-destructive disconnect UI | yes | not currently | needed |
| Non-destructive disconnect UI | yes | yes, bottom-row save/restore status line | implemented |
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel |
| Local TCP forwarding, `-L` | no | not implemented | planned |
| Remote TCP forwarding, `-R` | no | not implemented | planned |
| Dynamic SOCKS forwarding, `-D` | no | not implemented | later |
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority, replay/reorder, and stream retransmit tested |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; policy and stream mux covered |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented over the same reliable stream mux |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests |
## SSH Config Inheritance
@@ -81,41 +95,126 @@ Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host
configured. To write explicit Dosh host entries from SSH aliases:
```bash
dosh import-ssh palav homelab
dosh import-ssh homelab
```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Forwarding Plan
## Optional Command Extensions
Dosh can expose companion tools without taking a dependency on them. Command
extensions live in client or host config and expand only the first trailing word:
```toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
With that config, `dosh homelab tm` runs `tm` in the remote Dosh shell and
`dosh homelab tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
Host config can override a global extension, or disable it:
```toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
because that would remove Dosh's fast reconnect advantage and would break after
roaming. Dosh forwarding needs native encrypted channels over the Dosh transport.
Minimum viable forwarding design:
roaming. Dosh forwarding therefore runs as native encrypted stream channels over the
Dosh transport, and is now implemented.
| CLI | Meaning |
| --- | --- |
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server; client connects to target. |
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server (loopback by default); client connects to target. |
| `dosh -D 1080 host` | SOCKS5 listener on the client; server connects to whatever the SOCKS request names. |
Protocol work required:
Implemented:
- Add stream-open, stream-data, stream-ack, and stream-close packet types.
- Use per-stream flow control separate from terminal frame ordering.
- Never let bulk forwarding traffic delay terminal input/output packets.
- Bind forwarding permissions to the same SSH-authenticated user as the terminal.
- Add tests with dropped/reordered UDP packets.
- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/
`StreamEof`/`StreamClose` packet types.
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
frames, so bulk forwarding does not block PTY input/output.
- Ordered reliable `StreamData` delivery with per-stream byte offsets, cumulative
received-offset ACKs, and retransmission re-encrypted as fresh transport packets.
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
`--local-auth` and requires the native auth path.
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key
`no-port-forwarding` and `permitopen=`.
- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only
after listeners are ready).
This is a publishable differentiator once implemented, but it should not be claimed
until it exists.
Still open before claiming forwarding parity:
- More real-host load soak. The integration suite already covers terminal-priority
behavior while a local forward is under blocked-stream pressure, plus hostile
replay/reorder and server-to-client stream retransmission after UDP loss.
## Native v1 Verification Checklist Status
This maps each item in `NATIVE_V1_SPEC.md` section 16 to its status based on what the
code in `src/` actually does. Done = implemented and exercised by tests or obvious
from code; in progress = partially implemented; pending = not yet implemented.
| Spec section 16 item | Status | Evidence / note |
| --- | --- | --- |
| Unknown host key fails unless TOFU explicitly enabled | done | Client refuses `KnownHostStatus::Unknown` unless `trust_on_first_use`. |
| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. |
| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. |
| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. |
| Native ECDSA P-256 auth | done | ssh-agent and OpenSSH identity-file paths are wired; `native_user_auth_accepts_ecdsa_p256_private_key` verifies the direct key path. |
| Native RSA-SHA2 auth | done | ssh-agent requests `rsa-sha2-512`; direct OpenSSH RSA identities sign with `rsa-sha2-512`; legacy SHA-1 `ssh-rsa` signatures are rejected. |
| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. |
| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. |
| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. |
| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. |
| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. |
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
| Native cold auth beats cold `ssh host true` | done | Docker gate runs `dosh-bench --cold-native` after one-time `dosh trust` and requires Dosh mean <= SSH mean. |
| Cached attach near network RTT | done | Local loopback samples are ~3 ms; Docker cached gate is under 25 ms. See `docs/BENCHMARKS.md`. |
| `-L` works without delaying terminal input | done | Per-stream windowing plus `native_local_forward_bulk_load_does_not_delay_interactive_terminal` and blocked-stream priority regression. |
| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. |
| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. |
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
| Multiple forwards in one command | done | Forward lists parsed and started together. |
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | done | `make soak-local` passed `sleep_roaming_soak_30m` for 1803.54s locally. Keep rerunning before tagged releases. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
| Large forwarded transfers add no visible input lag | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. |
| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. |
| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: implemented for native auth and covered by
unit/integration tests. It still needs real-host tuning under abusive traffic.
- Protocol VERSION handling: v1 policy is single-version, fail-closed reject at the
packet/header and native-handshake layers. See `docs/PROTOCOL_VERSIONING.md`.
- ECDSA P-256 / SHA-2 RSA user keys: implemented for ssh-agent and OpenSSH
identity-file native auth. RSA is compatibility-only and uses SHA-2 signatures;
legacy SHA-1 `ssh-rsa` signatures are not accepted.
## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Add a non-destructive disconnect indicator.
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
bracketed paste, and terminal cleanup.
- Run `make soak-local` before each tagged release to refresh 30-minute sleep/roaming evidence.
- Run `make fuzz-deep` before launch, or use the scheduled/manual CI fuzz pass, and
publish the target durations.
- Keep scripted TUI tests green and add a broader app matrix for real alternate-screen
tools.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
- Stress-test `persist_sessions = true` before making restart-survivable holders the
default.
- Tune the native auth token bucket under abusive real-host traffic.
- Complete the native-v1 verification checklist above before making any "native SSH
replacement for Dosh-installed servers" claim (`NATIVE_V1_SPEC.md` section 17).
+127
View File
@@ -0,0 +1,127 @@
# Dosh Release Evidence - 2026-06-20
Runtime code benchmarked: `b44ff8e Improve release and benchmark tooling`
Release docs/default cleanup after that benchmark removed personal host aliases from
public examples and test fixtures; it did not change release runtime paths.
Environment:
- Host: local homelab runner
- Benchmark harness: `make bench-docker-mosh`
- Container SSH server: OpenSSH 9.6p1 on Ubuntu
- Same container, key, DNS, loopback, and Dosh server path for all Docker samples
## Release Artifacts
Generated locally with:
```bash
sh scripts/package-release.sh
```
Artifacts:
| artifact | sha256 |
| --- | --- |
| `dosh-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-0.1.0-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
| `dosh-0.1.0-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
The installer verifies `<artifact>.sha256` when the sidecar is published.
## Docker SSH/Mosh Benchmark
Command:
```bash
make bench-docker-mosh
```
### Cold SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.58 | 220.27 | 224.64 | 220.66 | 225.12 |
| `dosh_attach_ms` | 3 | 232.37 | 235.56 | 239.13 | 235.82 | 239.53 |
Raw samples:
- `ssh_true_ms`: [216.58, 220.27, 225.12]
- `dosh_attach_ms`: [235.56, 232.37, 239.53]
Gate: `dosh_attach_ms avg 235.82ms <= ssh avg 220.66ms + 500.00ms`
### ControlMaster SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 7.29 | 7.50 | 47.51 | 22.25 | 51.95 |
| `dosh_attach_ms` | 3 | 16.62 | 17.01 | 17.77 | 17.16 | 17.85 |
Raw samples:
- `ssh_true_ms`: [51.95, 7.29, 7.50]
- `dosh_attach_ms`: [17.01, 16.62, 17.85]
Gate: `dosh_attach_ms avg 17.16ms <= ssh avg 22.25ms + 500.00ms`
### Native Cold Auth
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 5 | 215.72 | 222.68 | 227.05 | 221.32 | 227.82 |
| `dosh_cold_native_ms` | 5 | 8.63 | 8.84 | 9.16 | 8.88 | 9.23 |
Raw samples:
- `ssh_true_ms`: [216.40, 223.99, 227.82, 222.68, 215.72]
- `dosh_cold_native_ms`: [8.84, 8.63, 9.23, 8.88, 8.84]
Gate: `dosh_cold_native_ms avg 8.88ms <= ssh avg 221.32ms + 0.00ms`
### Cached Attach
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `dosh_cached_attach_ms` | 10 | 8.07 | 8.96 | 9.83 | 8.98 | 9.84 |
Raw samples:
- `dosh_cached_attach_ms`: [8.07, 9.09, 9.02, 8.66, 8.71, 8.90, 8.45, 9.84, 9.25, 9.82]
Gate: `dosh_cached_attach_ms avg 8.98ms <= 25.00ms`
### Mosh Comparison
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.48 | 218.93 | 219.49 | 218.32 | 219.55 |
| `dosh_attach_ms` | 3 | 229.59 | 233.00 | 233.31 | 231.98 | 233.35 |
| `mosh_start_true_ms` | 3 | 527.13 | 563.34 | 573.09 | 554.89 | 574.18 |
Raw samples:
- `ssh_true_ms`: [219.55, 216.48, 218.93]
- `dosh_attach_ms`: [233.00, 229.59, 233.35]
- `mosh_start_true_ms`: [563.34, 527.13, 574.18]
## Soak And Fuzz Evidence
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: `sleep_roaming_soak_30m ... ok`, finished in `1803.54s`
- `make fuzz-smoke`: passed all configured targets for 20s each:
`packet_decode`, `from_body`, `authorized_keys`, `known_hosts`,
`handshake_structs`, `attach_ticket`
## Public Claim Boundary
This evidence supports:
- Dosh cached attach and native auth are much faster than cold SSH/Mosh startup in
this benchmark environment.
- Dosh remains mosh-shaped, not mosh-compatible.
- Dosh does not claim generic SSH replacement compatibility.
- Native v1 security still needs external review before making hard public security
claims.
+241
View File
@@ -0,0 +1,241 @@
# Dosh Threat Model
This is the published threat model for Dosh native v1, derived from
`docs/NATIVE_V1_SPEC.md` sections 4-6. It states the assets Dosh protects, the
attackers it does and does not defend against, the security properties Dosh claims
relative to SSH, the cryptographic building blocks actually in use, and an honest
list of accepted residual risks and known gaps.
Dosh is a remote-login transport for Dosh-installed servers. It is intended to
replace the day-to-day `ssh host` workflow for terminals and TCP forwarding while
keeping OpenSSH as a recovery and bootstrap fallback. Dosh is **not** an
RFC-compatible SSH implementation and does not claim SSH's entire protocol security
surface. It claims security that is **equivalent to, and in some respects stronger
than, SSH for the Dosh terminal and forwarding use case** on hosts running
`dosh-server`.
## 1. Assets
Dosh protects:
- **Terminal session contents.** Keystrokes, command output, and the authoritative
screen state for every named or generated session.
- **Forwarded TCP streams.** Bytes carried over `-L`, `-R`, and `-D` channels.
- **User authentication credentials.** The user's SSH/Dosh private keys and any
ssh-agent identities. Dosh never sees private key material in plaintext on the
wire; signatures are produced locally or by the agent.
- **Server-issued credentials.** Session keys, `ClientId` association state,
server-sealed attach tickets, and the client-held attach-ticket PSK.
- **Server identity.** The persistent Dosh host key (`~/.config/dosh/host_key`) and
the server secret used to seal attach tickets and derive bootstrap material.
- **Host-trust state.** The client's pinned known-hosts file
(`~/.config/dosh/known_hosts`).
- **Authorization policy.** `~/.ssh/authorized_keys` /
`~/.config/dosh/authorized_keys` and the forwarding policy they encode.
## 2. Attackers
### In scope (Dosh must defend against these)
- **Passive network observer.** May record all UDP traffic between client and
server.
- **Active network attacker.** May spoof, drop, replay, reorder, or modify any
packet, and may attempt to inject forged packets in either direction.
- **NAT rebinding / roaming.** Client source IP and port may change mid-session,
including across sleep, network switch, and NAT timeout.
- **Stolen attach-ticket cache without the user's private key.** An attacker who
reads a client cache copies a server-sealed ticket plus its PSK but does not have
the user's SSH private key.
- **Server restart and key rotation.** Stale session keys, tickets, and replay
state must not be usable after the server rotates its secret or host key.
- **Malicious unauthenticated client flooding auth attempts.** A peer that has no
authorized key tries to exhaust server resources or guess credentials.
- **Compromised low-privilege local user on a shared client.** A different local
user attempts to read Dosh credential caches on the same machine.
### Out of scope (explicitly not defended against)
- **Compromised client machine.** If the endpoint running `dosh-client` is owned by
the attacker, the attacker has the user's keys and terminal.
- **Compromised server account.** If the login account on the server is owned, the
attacker already has the shell Dosh would have given them.
- **Malicious kernel, terminal emulator, or PTY implementation** on either side.
- **A server that was legitimately authorized and later turns malicious.** Host-key
pinning detects a *substituted* server, not a trusted server that decides to
misbehave.
These exclusions match SSH's own boundaries: SSH likewise cannot protect a
compromised endpoint or a malicious authorized peer.
## 3. Security Properties Claimed vs SSH
| Property | SSH | Dosh native v1 | Notes |
| --- | --- | --- | --- |
| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. |
| User authentication by private-key possession | yes | yes | Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH key; signature binds the full transcript. |
| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. |
| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. |
| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. |
| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. |
| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/signature crates only. |
| Fail-closed downgrade behavior | yes | yes | Native auth failure surfaces an explicit error and SSH fallback is explicit; it never silently drops to an unauthenticated mode. |
| Fast resumption without re-auth | ControlMaster only | yes, native | Cached session/ticket attach skips a fresh round of public-key proof; this is a deliberate speed/security trade discussed in section 6. |
### Where Dosh aims to *exceed* SSH for this use case
- **Tighter transcript binding.** A user-auth signature binds both ephemeral keys,
both randoms, the server host key, requested user/session/mode/terminal size,
selected algorithms, and protocol version into one transcript. This forecloses
cross-protocol and partial-replay confusion classes for the narrow Dosh surface.
- **Smaller attack surface.** Dosh deliberately omits the full SSH transport/channel
machinery, arbitrary subsystems, X11, SFTP/SCP, and forced-command subsystems.
Fewer features means fewer parsers and fewer reachable states.
- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable
known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail
mismatch path, rather than the looser default TOFU behavior most SSH clients ship.
- **Modern primitives only.** Ed25519 and X25519 by default; ECDSA P-256 and
RSA-SHA2 are accepted for SSH-key compatibility; no DSA, no SHA-1 signatures, no
CBC-and-MAC constructions.
Dosh does **not** claim generic SSH compatibility and must not be described as an
SSH-protocol implementation.
## 4. Cryptographic Building Blocks (as implemented)
These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
`src/protocol.rs`, not just the spec.
- **Key exchange:** X25519 ephemeral-ephemeral (`x25519-dalek`). The shared secret
is checked for contributory behaviour; a non-contributory result is rejected.
- **Handshake/transport KDF:** HKDF-SHA256 (`hkdf`), salted with the SHA-256 of the
serialized `ClientHello` and `ServerHello`, binding traffic keys to the transcript.
- **AEAD:** ChaCha20-Poly1305 (`chacha20poly1305`) for every encrypted packet and
for sealed attach tickets. Nonces are derived as `direction || sequence`, giving a
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
not selectable today.
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
- **User-auth signatures:** Ed25519, ECDSA P-256, and RSA-SHA2, produced either by
ssh-agent over a Unix socket (`src/ssh_agent.rs`) or from an encrypted/plaintext
OpenSSH private key (`ssh-key`). The signature covers the user-auth transcript
described above. RSA public keys are matched as `ssh-rsa` authorized keys, but
native signatures must be `rsa-sha2-256` or `rsa-sha2-512`; legacy SHA-1
`ssh-rsa` signatures are rejected.
- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
derived session keys, with attach tickets sealed under an HKDF-derived
ticket key. Token comparison is constant-time.
- **Hashing/transcript:** SHA-256 (`sha2`).
- **Randomness:** OS CSPRNG via `rand::thread_rng()` / `OsRng`.
No homegrown ciphers, MACs, padding schemes, or key derivation are used. No nonce or
key pair is reused within a direction.
## 5. How the Properties Are Enforced (handshake and transport)
- **Server authentication.** `ServerHello` carries the host public key and an Ed25519
signature over `dosh/native/server-hello/v1 || ClientHello || ServerHello(unsigned)`.
The client verifies this signature *and* checks the host key against its pinned
known-hosts entry before sending user auth or accepting terminal bytes. Unknown
keys are refused unless TOFU is enabled; mismatches hard-fail with both
fingerprints and the file path.
- **User authentication.** `UserAuth` carries an Ed25519 signature over
`dosh/native/user-auth/v1 || ClientHello || ServerHello || UserAuth(unsigned)`. The
server looks the public key up in `authorized_keys`, enforces authorized-key
options, then verifies the signature. A removed key can no longer authenticate.
- **Authorization options.** `from=` (with CIDR, glob, and negation),
`no-port-forwarding`, and `permitopen=` are enforced. `command=` is rejected for
native terminal login. Any unrecognized restrictive option fails closed rather than
being silently ignored.
- **Forwarding policy.** The server enforces `allow_tcp_forwarding`,
`allow_remote_forwarding`, and a loopback-only default for remote binds
(`allow_remote_non_loopback_bind`), in addition to per-key `permitopen=` /
`no-port-forwarding`.
- **Replay protection.** A 128-wide sliding window over the per-direction packet
counter rejects duplicates and stale sequences. Sequence 0 is never accepted.
- **Stale-key tolerance.** Each encrypted packet carries a `session_key_id`; packets
under an old key are dropped as "stale or wrong session key id" rather than treated
as fatal decrypt failures, so reconnect after rotation is non-destructive.
- **Roaming.** The server keys clients by `ClientId` and session key id, not by
source address, and updates the endpoint after any valid encrypted packet from a
new address — without weakening authentication, because the packet must still
decrypt and verify.
## 6. Accepted Residual Risks and Known Gaps
These are stated openly so the public claim gate (`NATIVE_V1_SPEC.md` section 17) can
be evaluated honestly. Items here are *not* yet "green".
### Accepted residual risks (by design)
- **Attach tickets prove recent server-issued possession, not fresh private-key
possession.** A stolen client cache containing both the sealed ticket and its PSK
can attach until the ticket expires (default TTL 24h, configurable down to zero) or
until the server rotates its secret/host key or the user key is removed. This is the
deliberate speed trade. It is bounded by TTL, scoped to host/user/session/mode, and
can be disabled. SSH ControlMaster has an analogous live-socket exposure.
- **Local cache confidentiality relies on filesystem permissions.** Host keys,
server secret, known-hosts, and credential caches are written `0600`. A
same-machine attacker who can already read another user's `0600` files (e.g. via
root) is out of scope, as with SSH's `~/.ssh`.
- **A trusted server that turns malicious is not detected.** Host-key pinning
detects substitution, not betrayal by an already-authorized server. This matches
SSH.
### Known gaps / work in progress (must close before the public claim)
- **Native-auth rate limiting needs tuning, not first implementation.** The server
enforces a per-source token bucket before expensive native-auth work, evicts
half-finished handshakes on a TTL, and reports remaining capacity in
`ServerHello`. It is covered by unit/integration tests, but still needs abusive
real-host tuning before public hardening claims.
- **Protocol VERSION compatibility is intentionally single-version for v1.** The
packet header rejects any non-matching `VERSION` byte and the native handshake
rejects any non-matching `protocol_version`; peers get a named upgrade error, not
a silent downgrade. `docs/PROTOCOL_VERSIONING.md` defines the bump rules and the
post-v1 requirements for any future multi-version negotiation.
- **Deep fuzzing still needs launch evidence.** CI runs parser/auth fuzz targets for
20 seconds per target on push/PR and 300 seconds per target on weekly/manual runs
when nightly/cargo-fuzz is available. That catches obvious panics and parser
robustness regressions; public security claims should cite a completed
`make fuzz-deep` or scheduled CI run with durations.
- **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256,
and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
identity files. RSA remains compatibility-only and deliberately rejects legacy
SHA-1 `ssh-rsa` signatures.
- **Forwarded stream data uses ordered retransmission.** Streams carry byte offsets,
cumulative received-offset ACKs, and retransmit unacknowledged chunks as fresh
encrypted transport packets. Hostile-network tests cover replay/reorder and
server-to-client stream recovery after deliberate UDP loss; broader real-host load
soak remains launch evidence.
- **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and
multi-client tests exist, and `sleep_roaming_soak_30m` / `make soak-local` provide
the 30-minute sleep/roaming gate. That gate should be run and published before
public security/reliability claims.
- **No third-party audit claim.** Dosh maintains a public threat model and hardening
checklist, but should not market itself as externally audited unless that actually
happens.
### Auth posture
Native auth is **opt-in alongside SSH fallback**. `auth_preference` defaults to
`native,ssh`: Dosh tries the native authenticated path first and falls back to SSH
bootstrap explicitly and visibly when native auth is disabled, unavailable, or
rejected. Native auth failure never silently degrades to an unauthenticated mode.
Forwarding (`-L`/`-R`/`-D`) requires the native authenticated path and refuses to run
under `--local-auth`.
## 7. Verification and Public-Claim Status
Dosh may claim "native SSH replacement for Dosh-installed servers" only after the
conditions in `NATIVE_V1_SPEC.md` section 17 are met: native auth default on a real
host, SSH fallback available, the section 16 checklist green, this threat model
published, and benchmarks with raw samples for SSH cold, Dosh native cold, Dosh
cached attach, and Mosh startup.
Current status: this threat model is published (this document). The verification
checklist is **not yet fully green** — see the item-by-item status table in
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
gaps in section 6 above. Until the gaps close, Dosh's defensible public claim remains
**fast, encrypted native attach/reconnect and forwarding with SSH bootstrap
fallback** on Dosh-installed servers, not generic SSH compatibility or a third-party
audited security product.
+5
View File
@@ -0,0 +1,5 @@
/target
/corpus
/artifacts
/coverage
Cargo.lock
+64
View File
@@ -0,0 +1,64 @@
[package]
name = "dosh-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
# Standalone workspace so this crate is never absorbed by, and never affects,
# the main `dosh` crate's `cargo build` / `cargo test` / `cargo fmt --check`.
[workspace]
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.dosh]
path = ".."
# cargo-fuzz needs unwinding to report panics; keep debug assertions on.
[profile.release]
debug = 1
[[bin]]
name = "packet_decode"
path = "fuzz_targets/packet_decode.rs"
test = false
doc = false
bench = false
[[bin]]
name = "from_body"
path = "fuzz_targets/from_body.rs"
test = false
doc = false
bench = false
[[bin]]
name = "authorized_keys"
path = "fuzz_targets/authorized_keys.rs"
test = false
doc = false
bench = false
[[bin]]
name = "known_hosts"
path = "fuzz_targets/known_hosts.rs"
test = false
doc = false
bench = false
[[bin]]
name = "handshake_structs"
path = "fuzz_targets/handshake_structs.rs"
test = false
doc = false
bench = false
[[bin]]
name = "attach_ticket"
path = "fuzz_targets/attach_ticket.rs"
test = false
doc = false
bench = false
+67
View File
@@ -0,0 +1,67 @@
# 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
# Run all targets for a short smoke pass
make fuzz-smoke
# Run all targets for the pre-launch deep pass (default: 300s each)
make fuzz-deep
# List targets
cargo +nightly fuzz list --fuzz-dir fuzz
# Run a single target indefinitely
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode
# Short, CI-style smoke run of one target (10 seconds)
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode -- -max_total_time=10
```
Or from inside `fuzz/`:
```sh
cd fuzz
cargo +nightly fuzz run packet_decode -- -max_total_time=10
```
## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
cargo-fuzz and runs each target. Push/PR runs use a short 20-second-per-target
smoke pass. Weekly scheduled and manual workflow runs use a 300-second-per-target
deep pass by default. The job is tolerant if the toolchain/tooling is unavailable
so it never blocks the main test gate.
+20
View File
@@ -0,0 +1,20 @@
#![no_main]
//! Fuzz the attach-ticket and bootstrap decoders. These open server-sealed
//! AEAD blobs and base64 bootstrap envelopes from cache / wire material that an
//! attacker may corrupt; none may panic.
use libfuzzer_sys::fuzz_target;
use dosh::auth::{decode_bootstrap, open_attach_ticket, verify_attach_ticket};
fuzz_target!(|data: &[u8]| {
let secret = [0x11u8; 32];
let psk = [0x22u8; 32];
let _ = open_attach_ticket(&secret, data);
let _ = verify_attach_ticket(&secret, data, &psk, "default", "read-write");
if let Ok(text) = std::str::from_utf8(data) {
let _ = decode_bootstrap(text);
}
});
+18
View File
@@ -0,0 +1,18 @@
#![no_main]
//! Fuzz the authorized_keys parser, including its option lexer and the
//! ssh-ed25519 public-key blob parser it depends on. None may panic.
use libfuzzer_sys::fuzz_target;
use dosh::native::{parse_authorized_keys, parse_ssh_ed25519_public_blob};
fuzz_target!(|data: &[u8]| {
// The blob parser operates directly on raw bytes.
let _ = parse_ssh_ed25519_public_blob(data);
// The line parser operates on text; only feed valid UTF-8 (lossless),
// matching how the file is read in production via read_to_string.
if let Ok(text) = std::str::from_utf8(data) {
let _ = parse_authorized_keys(text);
}
});
+62
View File
@@ -0,0 +1,62 @@
#![no_main]
//! Fuzz `protocol::from_body` (bincode deserialization) for every protocol and
//! native struct that is decoded from untrusted wire bytes. None may panic.
use libfuzzer_sys::fuzz_target;
use dosh::auth::{AttachTicketPlain, BootstrapResponse, SealedAttachTicket};
use dosh::native::{
HostPublicKey, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Input, NativeAuthCheckOkBody,
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, Resize,
ResumeRequest, StreamClose, StreamData, StreamEof, StreamOpen, StreamOpenOk, StreamOpenReject,
StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
};
macro_rules! try_body {
($data:expr, $ty:ty) => {
let _ = protocol::from_body::<$ty>($data);
};
}
fuzz_target!(|data: &[u8]| {
// protocol.rs structs
try_body!(data, BootstrapAttachRequest);
try_body!(data, TicketAttachEnvelope);
try_body!(data, TicketAttachBody);
try_body!(data, TicketAttachOkEnvelope);
try_body!(data, AttachOk);
try_body!(data, AttachReject);
try_body!(data, ResumeRequest);
try_body!(data, Input);
try_body!(data, Resize);
try_body!(data, Frame);
try_body!(data, StreamOpen);
try_body!(data, StreamOpenOk);
try_body!(data, StreamOpenReject);
try_body!(data, StreamData);
try_body!(data, StreamWindowAdjust);
try_body!(data, StreamEof);
try_body!(data, StreamClose);
// native handshake wrapper bodies
try_body!(data, NativeClientHelloBody);
try_body!(data, NativeServerHelloBody);
try_body!(data, NativeUserAuthBody);
try_body!(data, NativeAuthOkBody);
try_body!(data, NativeAuthCheckOkBody);
// bare native handshake structs
try_body!(data, NativeClientHello);
try_body!(data, NativeServerHello);
try_body!(data, NativeUserAuth);
try_body!(data, NativeAuthOk);
try_body!(data, HostPublicKey);
// auth.rs structs
try_body!(data, BootstrapResponse);
try_body!(data, SealedAttachTicket);
try_body!(data, AttachTicketPlain);
});
+44
View File
@@ -0,0 +1,44 @@
#![no_main]
//! Fuzz the native handshake structs and their structural verifiers.
//!
//! Beyond plain deserialization (covered by the `from_body` target), this drives
//! the verifier state machine: if the input happens to decode into the handshake
//! structs, run `verify_server_hello`, `user_auth_transcript`, and
//! `verify_native_user_auth` on them. These run on attacker-controlled material
//! during the handshake and must reject (Err) without panicking.
use libfuzzer_sys::fuzz_target;
use dosh::native::{
NativeClientHello, NativeServerHello, NativeUserAuth, user_auth_transcript,
verify_native_user_auth, verify_server_hello,
};
use dosh::protocol;
fuzz_target!(|data: &[u8]| {
// Split the input into three slices and try to decode each into a handshake
// struct. Use a length prefix scheme that is robust to short inputs.
if data.len() < 3 {
return;
}
let n = data.len();
let a = n / 3;
let b = 2 * n / 3;
let (chunk_client, chunk_server, chunk_auth) = (&data[..a], &data[a..b], &data[b..]);
let client: Option<NativeClientHello> = protocol::from_body(chunk_client).ok();
let server: Option<NativeServerHello> = protocol::from_body(chunk_server).ok();
let auth: Option<NativeUserAuth> = protocol::from_body(chunk_auth).ok();
if let (Some(client), Some(server)) = (&client, &server) {
// Host signature verification over the transcript must not panic.
let _ = verify_server_hello(client, server);
if let Some(auth) = &auth {
// Transcript construction and full user-auth verification (signature
// check + authorized-key matching) must not panic on garbage.
let _ = user_auth_transcript(client, server, auth);
let _ = verify_native_user_auth(client, server, auth, &[], None);
}
}
});
+14
View File
@@ -0,0 +1,14 @@
#![no_main]
//! Fuzz the known_hosts parser and the host-public-key line parser. None may
//! panic on arbitrary input.
use libfuzzer_sys::fuzz_target;
use dosh::native::{parse_host_public_key_line, parse_known_hosts};
fuzz_target!(|data: &[u8]| {
if let Ok(text) = std::str::from_utf8(data) {
let _ = parse_known_hosts(text);
let _ = parse_host_public_key_line(text);
}
});
+23
View File
@@ -0,0 +1,23 @@
#![no_main]
//! Fuzz the wire-packet decoder + decrypt path.
//!
//! Mirrors tests/parser_robustness.rs but driven by libFuzzer so the coverage
//! engine can search for panics in `protocol::decode`, `Header::parse`, and the
//! decode -> decrypt_body pipeline. The objective is: NO PANICS on any input.
use libfuzzer_sys::fuzz_target;
use dosh::protocol::{self, CLIENT_TO_SERVER, SERVER_TO_CLIENT};
fuzz_target!(|data: &[u8]| {
// Header parsing must never panic.
let _ = protocol::Header::parse(data);
// Full decode, then attempt decryption with a fixed key in both directions.
// A real attacker controls these bytes; neither path may panic.
if let Ok(packet) = protocol::decode(data) {
let key = [0x42u8; 32];
let _ = protocol::decrypt_body(&packet, &key, CLIENT_TO_SERVER);
let _ = protocol::decrypt_body(&packet, &key, SERVER_TO_CLIENT);
}
});
+171 -47
View File
@@ -6,6 +6,12 @@ param(
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
[switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"),
[string]$BinaryUrl = $env:DOSH_BINARY_URL,
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
[string]$BinaryName = $env:DOSH_BINARY_NAME,
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
[switch]$ForceConfig
)
@@ -17,39 +23,157 @@ function Require-Command($Name) {
}
}
Require-Command cargo
$tmp = $null
if (Test-Path "Cargo.toml") {
$src = (Get-Location).Path
} else {
if (-not $Repo) {
throw "DOSH_REPO is required when running the installer from irm/iex"
function Normalize-Arch {
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "x86_64"; break }
"ARM64" { "aarch64"; break }
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
}
Require-Command git
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid())
git clone --depth 1 $Repo $tmp | Out-Null
$src = $tmp
}
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
function Release-ArtifactName {
if ($BinaryName) {
return $BinaryName
}
"dosh-windows-$(Normalize-Arch).zip"
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
function Repo-WebBase($Value) {
if (-not $Value) {
return $null
}
$base = $Value.TrimEnd("/")
if ($base.EndsWith(".git")) {
$base = $base.Substring(0, $base.Length - 4)
}
if ($base -notmatch "^https?://") {
return $null
}
$base
}
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
function Release-DownloadUrl {
if ($BinaryUrl) {
return $BinaryUrl
}
$name = Release-ArtifactName
if ($BinaryBase) {
return "$($BinaryBase.TrimEnd('/'))/$name"
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
if ($BinaryVersion -eq "latest") {
return "$web/releases/latest/download/$name"
}
"$web/releases/download/$BinaryVersion/$name"
}
$clientConfig = Join-Path $configDir "client.toml"
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
$defaultServer = if ($Server) { $Server } else { "user@example.com" }
$updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" }
$doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" }
@"
function Verify-ArchiveChecksum($Url, $Archive) {
$checksumPath = "$Archive.sha256"
try {
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
}
catch {
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
return
}
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
if ($expected -ne $actual) {
throw "prebuilt checksum mismatch for $Url"
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
function Install-Prebuilt {
$url = Release-DownloadUrl
if (-not $url) {
return $false
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
$zip = Join-Path $tmp (Release-ArtifactName)
$extract = Join-Path $tmp "extract"
try {
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
Verify-ArchiveChecksum $url $zip
Expand-Archive -Force -Path $zip -DestinationPath $extract
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
}
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
return $true
}
catch {
Write-Warning "prebuilt install failed: $_"
return $false
}
finally {
if (Test-Path $tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
function Install-FromSource {
Require-Command cargo
$tmp = $null
if (Test-Path "Cargo.toml") {
$src = (Get-Location).Path
} else {
if (-not $Repo) {
throw "DOSH_REPO is required when running the installer from irm/iex"
}
Require-Command git
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid())
git clone --depth 1 $Repo $tmp | Out-Null
$src = $tmp
}
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
if ($UsePrebuilt) {
$ok = Install-Prebuilt
if (-not $ok) {
if ($BinaryRequired) {
throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1"
}
Write-Host "Falling back to source build"
Install-FromSource
}
} else {
Install-FromSource
}
$clientConfig = Join-Path $configDir "client.toml"
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
$defaultServer = if ($Server) { $Server } else { "user@example.com" }
$updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" }
$doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" }
@"
update_repo = "$updateRepo"
update_port = $Port
server = "$defaultServer"
@@ -60,28 +184,28 @@ dosh_port = $Port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not (($userPath -split ';') -contains $bindir)) {
[Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User")
}
Write-Host "Installed Dosh client to $bindir"
Write-Host "Configured UDP port $Port"
Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client command:"
Write-Host " $bindir\dosh.exe $displayServer"
Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply."
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not (($userPath -split ';') -contains $bindir)) {
[Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User")
}
Write-Host "Installed Dosh client to $bindir"
Write-Host "Configured UDP port $Port"
Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client commands:"
Write-Host " $bindir\dosh.exe $displayServer"
Write-Host " $bindir\dosh.exe setup <ssh-alias>"
Write-Host " $bindir\dosh.exe update --check"
Write-Host ""
Write-Host "Client config:"
Write-Host " $configDir\client.toml"
Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply."
+289 -72
View File
@@ -12,6 +12,12 @@ start_server=1
force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
binary_version="${DOSH_BINARY_VERSION:-latest}"
binary_required="${DOSH_BINARY_REQUIRED:-0}"
usage() {
cat <<'EOF'
@@ -32,6 +38,18 @@ Options:
Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=0
Build from source instead of trying a release tarball first
DOSH_BINARY_URL URL
Exact release tarball URL to install
DOSH_BINARY_BASE URL
Release download base; defaults to REPO/releases/latest/download
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
Release tag when deriving DOSH_BINARY_BASE; default latest
DOSH_BINARY_REQUIRED=1
Fail instead of falling back to source when binary install fails
EOF
}
@@ -112,8 +130,6 @@ ensure_cargo() {
. "$HOME/.cargo/env"
}
ensure_cargo
cleanup() {
if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir"
@@ -121,73 +137,267 @@ cleanup() {
}
trap cleanup EXIT INT TERM
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)"
else
if [ -z "$repo" ]; then
echo "DOSH_REPO or --repo is required when running the installer from curl" >&2
exit 2
fi
case "$update_cache" in
""|"/"|"$HOME"|"$HOME/"|"$HOME/.cache")
echo "refusing unsafe update cache path: $update_cache" >&2
exit 2
;;
esac
need git
mkdir -p "$(dirname "$update_cache")"
if [ -d "$update_cache/.git" ]; then
[ "$quiet" = "1" ] && echo "Updating Dosh source"
git -C "$update_cache" remote set-url origin "$repo"
if [ "$quiet" = "1" ]; then
git -C "$update_cache" fetch -q --depth 1 origin main
else
git -C "$update_cache" fetch --depth 1 origin main
fi
git -C "$update_cache" checkout -q -B main FETCH_HEAD
else
[ "$quiet" = "1" ] && echo "Downloading Dosh source"
rm -rf "$update_cache"
if [ "$quiet" = "1" ]; then
git clone -q --depth 1 --branch main "$repo" "$update_cache" >/dev/null
else
git clone --depth 1 --branch main "$repo" "$update_cache" >/dev/null
fi
fi
src_dir="$update_cache"
fi
cd "$src_dir"
[ "$quiet" = "1" ] && echo "Building Dosh $role"
if [ "$role" = "client" ]; then
if [ "$quiet" = "1" ]; then
cargo build -q --release --bin dosh-client
else
cargo build --release --bin dosh-client
fi
else
if [ "$quiet" = "1" ]; then
cargo build -q --release
else
cargo build --release
fi
fi
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
repo_web_base() {
repo_base="$1"
case "$repo_base" in
http://*|https://*)
repo_base="${repo_base%.git}"
printf '%s\n' "${repo_base%/}"
;;
*)
return 1
;;
esac
}
release_artifact_name() {
if [ -n "$binary_name" ]; then
printf '%s\n' "$binary_name"
else
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
fi
}
release_download_url() {
if [ -n "$binary_url" ]; then
printf '%s\n' "$binary_url"
return 0
fi
if [ -n "$binary_base" ]; then
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
return 0
fi
if [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
fi
}
release_latest_tag_download_url() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
;;
*)
return 1
;;
esac
}
find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install -m 0755 "$found" "$3"
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
return 1
fi
}
verify_archive_checksum() {
url="$1"
archive="$2"
checksum_file="$3"
if ! curl -fsL "$url.sha256" -o "$checksum_file"; then
echo "prebuilt checksum unavailable; continuing without sidecar verification" >&2
return 0
fi
expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')"
actual="$(sha256_file "$archive")" || {
echo "sha256sum/shasum not found for checksum verification" >&2
return 1
}
if [ "$expected" != "$actual" ]; then
echo "prebuilt checksum mismatch for $url" >&2
return 1
fi
}
try_install_prebuilt() {
download_url="$(release_download_url)" || return 1
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
need curl
need tar
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_latest_tag_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
return 1
fi
download_url="$alt_download_url"
fi
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
mkdir -p "$tmpdir/extract"
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
echo "prebuilt archive could not be extracted: $download_url" >&2
return 1
fi
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install -m 0755 "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
install_from_source() {
ensure_cargo
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)"
else
if [ -z "$repo" ]; then
echo "DOSH_REPO or --repo is required when running the installer from curl" >&2
exit 2
fi
case "$update_cache" in
""|"/"|"$HOME"|"$HOME/"|"$HOME/.cache")
echo "refusing unsafe update cache path: $update_cache" >&2
exit 2
;;
esac
need git
mkdir -p "$(dirname "$update_cache")"
if [ -d "$update_cache/.git" ]; then
[ "$quiet" = "1" ] && echo "Updating Dosh source"
git -C "$update_cache" remote set-url origin "$repo"
if [ "$quiet" = "1" ]; then
git -C "$update_cache" fetch -q --depth 1 origin main
else
git -C "$update_cache" fetch --depth 1 origin main
fi
git -C "$update_cache" checkout -q -B main FETCH_HEAD
else
[ "$quiet" = "1" ] && echo "Downloading Dosh source"
rm -rf "$update_cache"
if [ "$quiet" = "1" ]; then
git clone -q --depth 1 --branch main "$repo" "$update_cache" >/dev/null
else
git clone --depth 1 --branch main "$repo" "$update_cache" >/dev/null
fi
fi
src_dir="$update_cache"
fi
cd "$src_dir"
[ "$quiet" = "1" ] && echo "Building Dosh $role"
if [ "$role" = "client" ]; then
if [ "$quiet" = "1" ]; then
cargo build -q --release --bin dosh-client
else
cargo build --release --bin dosh-client
fi
else
if [ "$quiet" = "1" ]; then
cargo build -q --release
else
cargo build --release
fi
fi
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
fi
}
write_systemd_service() {
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=Dosh server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=on-failure
RestartSec=1
KillMode=process
[Install]
WantedBy=default.target
EOF
fi
}
if [ "$use_prebuilt" != "0" ]; then
if ! try_install_prebuilt; then
if [ "$binary_required" = "1" ]; then
exit 1
fi
[ "$quiet" = "1" ] && echo "Falling back to source build"
install_from_source
fi
else
install_from_source
fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -226,8 +436,7 @@ EOF
if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir"
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
write_systemd_service
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service
fi
@@ -272,7 +481,8 @@ dosh_port = $port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh"
@@ -295,18 +505,18 @@ EOF
fi
cat >"$hosts_config" <<EOF
# Example:
# [palav]
# ssh = "palav"
# dosh_host = "palav.dev"
# [homelab]
# ssh = "homelab"
# dosh_host = "server.example.com"
# port = 50000
# default_command = "tm"
# predict = false
# predict = true
[default]
ssh = "$default_server"
dosh_host = "$host_udp"
port = $port
predict = false
predict = true
EOF
fi
fi
@@ -317,10 +527,17 @@ Configured UDP port $port
EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF
Client command:
$bindir/dosh ${server:-user@host}
Client commands:
$bindir/dosh $next_server
$bindir/dosh setup <ssh-alias>
$bindir/dosh update --check
Client config:
$config_dir/client.toml
$config_dir/hosts.toml
EOF
fi
+201
View File
@@ -0,0 +1,201 @@
# dosh (Dormant Shell)
> dosh is a low-latency remote terminal for homelab/personal servers. It is mosh-shaped
> but not a mosh clone: `dosh-server` is a resident daemon that keeps terminal sessions
> hot, and the client reconnects over encrypted UDP — so attach and reconnect are
> near-instant (~3 ms of local overhead + one network RTT). SSH is used once to
> establish trust; after that, repeat attaches skip SSH entirely. It also does SSH-style
> TCP port forwarding (`-L`/`-R`/`-D`) over the same encrypted transport, which is what
> makes back-and-forth client↔server homelab comms easy.
This file orients an AI agent (or a human) on what dosh is, what it can do, and how to
drive it. It is intentionally self-contained. For deeper detail, see the linked docs at
the bottom.
## What dosh is for
Use dosh instead of `ssh`/`mosh` for **interactive shells and TCP forwarding** to a
server where you control both ends and have installed `dosh-server` (typically a homelab
box, VPS, or workstation). It shines when you:
- want a terminal that survives laptop sleep, Wi-Fi changes, and NAT rebinding, and
resumes instantly instead of hanging;
- reconnect to the same box many times a day and don't want to pay SSH startup each time;
- need to reach services on the server from your laptop (or vice-versa) without standing
up a VPN.
dosh is **not** a drop-in for every SSH use. It does not do `scp`/`sftp` file transfer,
X11, or act as an `sshd` for arbitrary SSH clients. Keep `ssh` installed for those.
## Core capabilities
- **Encrypted UDP terminal transport** — AEAD-encrypted, with packet sequencing, a
sliding replay window, ACKs, and server-side retransmit of unacked output.
- **Resident server + hot sessions** — `dosh-server` runs as a daemon; named sessions
(and a prewarmed `default`) stay alive across client disconnects.
- **Fast attach / reconnect** — see "Fast path order" below; cached attach is ~one RTT.
- **Roaming** — the session follows the client across IP/port changes.
- **Named & shared sessions** — reattach the same persistent terminal from multiple
clients; optional **view-only** clients.
- **TCP port forwarding** — local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) over
the encrypted transport, with per-stream flow control and terminal-priority
scheduling (bulk transfers don't lag your keystrokes).
- **Native UDP auth** — Ed25519 user auth via ssh-agent or an (optionally encrypted)
OpenSSH key, verified against `authorized_keys`. Falls back to SSH bootstrap when
native auth isn't available. SSH host config (`HostName`, `User`, `Port`,
`IdentityFile`, `ProxyJump`, etc.) is honored.
- **Host-key pinning** — TOFU/known-hosts with hard-fail on mismatch.
- **Speculative local echo** — optional predictive echo for laggy links (display-only;
real input is always sent to the server).
- **Ops commands** — `doctor`, `sessions`, `trust`, `import-ssh`, `update`.
## Quickstart
Install the server on each box you want to reach (default UDP port `50000`):
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_PORT=50000 sh -s -- server
```
Install the client (macOS/Linux), then attach:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_SERVER=homelab DOSH_HOST=homelab.example.com DOSH_PORT=50000 sh -s -- client
dosh homelab # fresh interactive shell
dosh homelab uptime # run one command
dosh --session work homelab # named, persistent, reattachable session
```
- Detach (leave the server session running): **Ctrl-]**.
- End the session: type `exit` in the remote shell.
- If UDP stalls, dosh keeps the terminal open, sends keepalives, and ticket-reconnects.
## Client↔server homelab comms (the back-and-forth)
Forwarding is the key to "make homelab comms easy." Three directions:
| Command | Direction | Effect |
| --- | --- | --- |
| `dosh -L [bind:]LPORT:THOST:TPORT host` | pull server→you | A listener on **your machine** (`bind`, default localhost) forwards to `THOST:TPORT` reached **from the server**. |
| `dosh -R [bind:]LPORT:THOST:TPORT host` | push you→server | A listener on **the server** (loopback by default) forwards to `THOST:TPORT` reached **from your machine**. |
| `dosh -D [bind:]LPORT host` | SOCKS via server | A SOCKS5 proxy on **your machine**; traffic egresses **from the server**. |
Concrete homelab patterns:
```bash
# Reach the homelab's internal Grafana (server-side :3000) from your laptop browser:
dosh -L 3000:127.0.0.1:3000 homelab # open http://localhost:3000
# Reach a DB that only listens on the homelab LAN:
dosh -L 5432:10.0.0.5:5432 homelab # psql -h 127.0.0.1 -p 5432
# Let the homelab hit a dev server running on your laptop (e.g. a webhook target):
dosh -R 9000:127.0.0.1:8080 homelab # homelab curls http://127.0.0.1:9000
# Route browser traffic out through the homelab's network:
dosh -D 1080 homelab # SOCKS5 proxy at 127.0.0.1:1080
# Forward-only, no shell; multiple forwards; background after listeners are up:
dosh -N -L 3000:127.0.0.1:3000 -L 5432:10.0.0.5:5432 homelab
dosh -f -N -L 3000:127.0.0.1:3000 homelab
```
Server policy controls forwarding: `allow_tcp_forwarding`, `allow_remote_forwarding`,
`allow_remote_non_loopback_bind`, and per-key `permitopen=`/`no-port-forwarding` in
`authorized_keys`. Remote listeners bind to loopback unless explicitly allowed.
## Fast path order (why it's quick)
The client tries the cheapest valid path first:
1. **UDP resume** — existing client id + session key; one encrypted UDP round-trip.
2. **UDP attach ticket** — cached server-issued ticket; one round-trip, no SSH.
3. **Native UDP auth** — Ed25519 handshake (ssh-agent/key) when enabled.
4. **SSH bootstrap** — `ssh user@host dosh-auth …` once, then a UDP attach.
Measured locally (loopback, release): cached attach ≈ **3 ms**, cold native auth ≈ 9 ms.
Over a real link, add one RTT. See `docs/BENCHMARKS.md`.
## Architecture (1-minute model)
- **dosh-server** — single UDP socket on one port; a session table keyed by name; one
PTY per named session; per-session terminal screen state (vt100) for snapshots/diffs;
a per-session client table; encrypted UDP protocol; a small SSH-invoked `dosh-auth`
helper. Abandoned (clientless, non-prewarmed) sessions and their shells are reaped
after a grace period; prewarmed sessions stay hot.
- **dosh-client** — raw-mode terminal; local credential cache; tries resume/ticket/native
before SSH; forwards PTY I/O; reconnect/roaming state machine; optional predictive echo.
- **Binaries** — `dosh-server`, `dosh-client` (symlinked as `dosh`), `dosh-auth`
(SSH-invoked trust helper), `dosh-bench` (benchmarks).
## Security model (summary)
- First trust via SSH; thereafter encrypted Dosh transport. Native auth available.
- KEX X25519; AEAD ChaCha20-Poly1305; KDF HKDF-SHA256; host & user auth Ed25519;
SHA-256 transcript binding. Per-direction, per-sequence nonces; replay window.
- Host keys pinned in `~/.config/dosh/known_hosts`; mismatch hard-fails.
- User auth against `~/.ssh/authorized_keys` (+ optional `~/.config/dosh/authorized_keys`);
removed keys can't authenticate; unsupported restrictive options fail closed.
- Forward secrecy from ephemeral X25519; attach tickets are server-sealed and scoped.
dosh claims security **equivalent to, and in places stronger than, SSH for the dosh
terminal/forwarding use case** — not full SSH-protocol parity. Read `docs/THREAT_MODEL.md`
for the honest stance, residual risks, and what's still pending before public claims.
## Configuration
- **Client** `~/.config/dosh/client.toml` — `auth_preference` (e.g. `"native,ssh"`),
`trust_on_first_use`, `identity_files`, `use_ssh_agent`, `forward_agent`, `send_env`,
`set_env`, `dosh_port`, `default_session`, `predict`, `reconnect_timeout_secs`,
`credential_cache`, `known_hosts`.
- **Hosts** `~/.config/dosh/hosts.toml` — per-alias `[name]` with `ssh`, `dosh_host`,
`port`, `user`, `default_command`, `predict`. Generate from SSH aliases with
`dosh import-ssh <alias> <name>`.
- **Server** `~/.config/dosh/server.toml` — `port`, `bind`, `shell`, `prewarm_sessions`,
`native_auth`, `host_key`, `authorized_keys`, `attach_ticket_ttl_secs`,
`client_timeout_secs`, `allow_tcp_forwarding`, `allow_remote_forwarding`,
`allow_remote_non_loopback_bind`, `allow_agent_forwarding`, `accept_env`,
`native_auth_rate_limit_per_minute`.
Paths: host key `~/.config/dosh/host_key`; credential cache
`~/.local/share/dosh/credentials/`.
## Operating & diagnostics
```bash
dosh doctor homelab # host resolution, trust state, UDP reachability, server
# version, usable keys, auth result, forwarding policy
dosh sessions homelab # list live sessions
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
dosh trust --remove homelab
dosh import-ssh homelab # write a hosts.toml entry from an SSH alias
dosh update # update the installed client
```
If something fails, `dosh doctor <host>` is the first stop — every public error is meant
to be actionable (host trust, auth failure, UDP blocked, forwarding denied, version
mismatch, server unavailable).
## For agents driving dosh
- Run one command and exit: `dosh <host> <cmd...>`. Render one frame and detach:
`dosh --attach-only <host>`. Both are non-interactive-friendly.
- A real terminal size is needed for full-screen rendering; with no TTY the client falls
back to 80×24. Pass `-v`/`-vv` for timing/diagnostic logs on stderr.
- Prefer `--session <name>` to reattach a known session; bare `dosh <host>` opens a
fresh, uniquely-named session each time.
- Never assume file transfer or X11 — use `ssh`/`scp` for those.
- Diagnostics are scriptable via `dosh doctor <host>`.
## Reference docs
- `README.md` — overview, install, develop, performance rules.
- `docs/NATIVE_V1_SPEC.md` — the native auth + forwarding v1 contract and verification
checklist (with current status).
- `docs/THREAT_MODEL.md` — security claims, threat model, residual risks.
- `docs/PUBLIC_READINESS.md` — feature matrix and §16 verification status.
- `docs/BENCHMARKS.md` — how to benchmark; metric definitions; sample numbers.
- `SPEC.md` — protocol/wire details.
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env sh
# Safe, self-contained local Dosh benchmark harness.
#
# Spins up a THROWAWAY dosh-server bound to 127.0.0.1 on a random free port in a
# temp HOME, runs the full path matrix (native cold auth, cached attach-ticket,
# UDP resume, and local-auth), prints raw samples + summary stats, then tears
# everything down.
#
# It NEVER touches the production server: it never uses UDP port 50000, never
# restarts any systemd unit, and never reads or writes the real ~/.config/dosh
# or ~/.local. Everything lives under a mktemp HOME that is removed on exit.
#
# Usage:
# scripts/bench-local.sh [ITERATIONS]
# Environment:
# DOSH_BENCH_ITERS iteration count (default 20; overridden by $1)
# DOSH_BENCH_JSON=1 emit machine-readable JSON instead of the table
# DOSH_BENCH_PROFILE release|debug build profile (default release)
set -eu
iters="${1:-${DOSH_BENCH_ITERS:-20}}"
profile="${DOSH_BENCH_PROFILE:-release}"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
# Make sure cargo is reachable in non-login shells.
if ! command -v cargo >/dev/null 2>&1; then
# shellcheck disable=SC1090
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
if [ "$profile" = "release" ]; then
cargo build --release >&2
bindir="$repo_root/target/release"
else
cargo build >&2
bindir="$repo_root/target/debug"
fi
server_bin="$bindir/dosh-server"
client_bin="$bindir/dosh-client"
bench_bin="$bindir/dosh-bench"
# Pick a free UDP port that is NOT the production port (50000).
free_udp_port() {
python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
}
dosh_port="$(free_udp_port)"
if [ "$dosh_port" = "50000" ]; then
dosh_port="$(free_udp_port)"
fi
if [ "$dosh_port" = "50000" ]; then
echo "refusing to bind production port 50000" >&2
exit 1
fi
home="$(mktemp -d)"
server_pid=""
cleanup() {
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
rm -rf "$home"
}
trap cleanup EXIT INT TERM
mkdir -p "$home/.config/dosh" "$home/.ssh" "$home/.local/share/dosh"
# Client identity used by the native cold-auth path.
ssh-keygen -t ed25519 -N "" -q -f "$home/.ssh/id_ed25519"
client_pub="$(cat "$home/.ssh/id_ed25519.pub")"
printf '%s\n' "$client_pub" > "$home/authorized_keys"
# Throwaway server config: localhost only, throwaway port, attach tickets on.
cat > "$home/.config/dosh/server.toml" <<EOF
port = $dosh_port
bind = "127.0.0.1"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 60
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "/bin/sh"
sessions_dir = "$home/sessions"
secret_path = "$home/secret"
host_key = "$home/host_key"
authorized_keys = ["$home/authorized_keys"]
EOF
# Client config: native auth, trust-on-first-use (no SSH needed), localhost UDP.
write_client_config() {
# $1 = cache_attach_tickets (true|false)
cat > "$home/.config/dosh/client.toml" <<EOF
server = "local"
dosh_host = "127.0.0.1"
dosh_port = $dosh_port
default_session = "default"
reconnect_timeout_secs = 5
view_only = false
predict = false
cache_attach_tickets = $1
credential_cache = "$home/.local/share/dosh/credentials"
known_hosts = "$home/.config/dosh/known_hosts"
auth_preference = "native"
trust_on_first_use = true
EOF
}
start_server() {
HOME="$home" "$server_bin" serve --config "$home/.config/dosh/server.toml" \
>"$home/server.log" 2>&1 &
server_pid="$!"
# Wait for the UDP socket to come up.
i=0
while [ "$i" -lt 50 ]; do
if grep -q "listening on" "$home/server.log" 2>/dev/null; then
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
echo "dosh-server exited early:" >&2
cat "$home/server.log" >&2 || true
exit 1
fi
i=$((i + 1))
sleep 0.1
done
}
run_bench() {
# $@ extra args forwarded to dosh-bench
json_flag=""
[ "${DOSH_BENCH_JSON:-0}" = "1" ] && json_flag="--json"
label="$(uname -s) $(uname -m), profile=$profile"
HOME="$home" "$bench_bin" \
--client "$client_bin" \
--server local \
--dosh-host 127.0.0.1 \
--dosh-port "$dosh_port" \
--session default \
--skip-ssh-baseline \
--iterations "$iters" \
--label "$label" \
$json_flag \
"$@"
}
echo "dosh local benchmark: port=$dosh_port iters=$iters profile=$profile home=$home" >&2
# 1) Native cold auth + cached attach-ticket in one run (ticket cache on).
write_client_config true
start_server
echo "== native cold auth + cached attach-ticket ==" >&2
run_bench --cold-native --cached-ticket
# Sanity: native cold auth must have trusted the host (proves the generated
# client config loaded and the native handshake ran instead of silently
# falling back to a different path).
if [ ! -s "$home/.config/dosh/known_hosts" ]; then
echo "native cold auth did not record a trusted host; check server.log:" >&2
cat "$home/server.log" >&2 || true
exit 1
fi
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
server_pid=""
# 2) Self-contained local-auth path (no SSH, no native handshake).
#
# Note on UDP resume: resume is the roaming path for a client that is STILL
# attached when its network endpoint changes. The `--attach-only` benchmark
# model detaches after each iteration, which tears the client down on the
# server, so a fresh-process resume has nothing live to resume and is not
# meaningful here. The cold fresh-process reconnect fast path is the attach
# ticket (measured above). Roaming resume is validated by the integration test
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
# scenarios that keep a live session out-of-band (e.g. remote soak tests); see
# docs/BENCHMARKS.md.
rm -rf "$home/.local/share/dosh/credentials"
write_client_config true
start_server
echo "== local-auth (no SSH) ==" >&2
run_bench --local-auth --no-cache
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out="${DOSH_BENCH_REPORT:-target/dosh-bench/report.md}"
iters="${DOSH_BENCH_ITERS:-10}"
server="${DOSH_BENCH_SERVER:-${1:-local}}"
label="${DOSH_BENCH_LABEL:-$(uname -s) $(uname -m)}"
cargo build --release >/dev/null
mkdir -p "$(dirname "$out")"
exec target/release/dosh-bench \
--server "$server" \
--iterations "$iters" \
--label "$label" \
--markdown \
--output "$out" \
${DOSH_BENCH_ARGS:-}
+27 -1
View File
@@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do
done
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh"
if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-client \
--ssh-port "$ssh_port" \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--ssh-auth-command /usr/local/bin/dosh-auth \
trust bench@127.0.0.1 >/dev/null; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
--dosh-port "$dosh_port" \
--dosh-host 127.0.0.1 \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--iterations 5 \
--cold-native \
--assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-cached" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}"
targets="
packet_decode
from_body
authorized_keys
known_hosts
handshake_structs
attach_ticket
"
if ! command -v cargo >/dev/null 2>&1; then
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
for target in $targets; do
echo "== fuzzing $target for ${seconds}s =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}"
done
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
version="${DOSH_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')}"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
MINGW*|MSYS*|CYGWIN*) printf '%s\n' windows ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
os="$(normalize_os)"
arch="$(normalize_arch)"
if [ "$os" = "windows" ]; then
artifact="dosh-$os-$arch.zip"
versioned_artifact="dosh-$version-$os-$arch.zip"
else
artifact="dosh-$os-$arch.tar.gz"
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
fi
stage="$out_dir/stage/dosh"
cargo build --release
rm -rf "$stage"
mkdir -p "$stage/bin" "$out_dir"
for bin in dosh-client dosh-server dosh-auth dosh-bench; do
if [ -f "target/release/$bin" ]; then
install -m 0755 "target/release/$bin" "$stage/bin/$bin"
fi
done
printf '%s\n' "$version" >"$stage/VERSION"
if [ "$os" = "windows" ]; then
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command "Compress-Archive -Force -Path '$stage' -DestinationPath '$out_dir/$artifact'"
elif command -v zip >/dev/null 2>&1; then
(cd "$out_dir/stage" && zip -qr "../$artifact" dosh)
else
echo "windows packaging requires powershell.exe or zip" >&2
exit 1
fi
else
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
fi
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
write_sha256() {
file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" >"$file.sha256"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" >"$file.sha256"
else
echo "warning: sha256sum/shasum not found; skipping checksum for $file" >&2
fi
}
write_sha256 "$out_dir/$artifact"
write_sha256 "$out_dir/$versioned_artifact"
cat <<EOF
Wrote:
$out_dir/$artifact
$out_dir/$artifact.sha256
$out_dir/$versioned_artifact
$out_dir/$versioned_artifact.sha256
EOF
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage:
GITEA_TOKEN=... scripts/upload-gitea-release.sh TAG [artifact...]
Environment:
GITEA_URL Base URL, default https://git.palav.dev
GITEA_REPO owner/repo, default Palav/dosh
GITEA_TOKEN Token with release write permission
GITEA_TITLE Release title, default TAG
EOF
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
tag="${1:-}"
if [ -z "$tag" ]; then
usage >&2
exit 2
fi
shift
base="${GITEA_URL:-https://git.palav.dev}"
repo="${GITEA_REPO:-Palav/dosh}"
token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}"
if [ -z "$token" ]; then
echo "GITEA_TOKEN is required" >&2
exit 2
fi
if [ "$#" -eq 0 ]; then
set -- target/dosh-release/dosh-*
fi
api="$base/api/v1/repos/$repo"
auth_header="Authorization: token $token"
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null || true)"
release_id=""
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
elif [ -n "$release_json" ]; then
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
if [ -z "$release_id" ]; then
payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")"
release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")"
if command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
else
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
fi
if [ -z "$release_id" ]; then
echo "could not determine Gitea release id for $tag" >&2
exit 1
fi
delete_existing_asset() {
name="$1"
if ! command -v jq >/dev/null 2>&1; then
return 0
fi
printf '%s\n' "$release_json" \
| jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' \
| while IFS= read -r asset_id; do
[ -n "$asset_id" ] || continue
echo "replacing $name"
curl -fsS \
-H "$auth_header" \
-X DELETE \
"$api/releases/$release_id/assets/$asset_id" >/dev/null
done
}
for artifact in "$@"; do
if [ ! -f "$artifact" ]; then
continue
fi
name="$(basename "$artifact")"
delete_existing_asset "$name"
echo "uploading $name"
curl -fsS \
-H "$auth_header" \
-X POST \
-F "attachment=@$artifact" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
done
+6
View File
@@ -68,6 +68,12 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
.decode(String::from_utf8_lossy(&raw).trim())
.context("decode server secret")?
};
anyhow::ensure!(
decoded.len() == 32,
"server secret at {} must be 32 bytes, got {}",
path.display(),
decoded.len()
);
let mut out = [0u8; 32];
out.copy_from_slice(&decoded[..32]);
return Ok(out);
+505 -92
View File
@@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow};
use clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::process::Command;
@@ -25,6 +27,15 @@ struct Args {
iterations: usize,
#[arg(long)]
local_auth: bool,
/// Benchmark native cold auth (no cache, native handshake) terminal-ready time.
#[arg(long)]
cold_native: bool,
/// Benchmark cached attach-ticket terminal-ready time (warms the cache first).
#[arg(long)]
cached_ticket: bool,
/// Benchmark UDP resume terminal-ready time (warms the cache first).
#[arg(long)]
resume: bool,
#[arg(long)]
client: Option<PathBuf>,
#[arg(long, default_value = "~/.local/bin/dosh-auth")]
@@ -51,6 +62,18 @@ struct Args {
mosh_server_command: String,
#[arg(long)]
mosh_port: Option<String>,
/// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)]
json: bool,
/// Emit a publishable Markdown benchmark report.
#[arg(long)]
markdown: bool,
/// Write the report to a file instead of stdout.
#[arg(long)]
output: Option<PathBuf>,
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)]
label: Option<String>,
#[arg(long)]
assert_ssh_plus_ms: Option<f64>,
#[arg(long)]
@@ -59,12 +82,45 @@ struct Args {
assert_dosh_max_ms: Option<f64>,
}
/// One Dosh attach path to benchmark.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoshPath {
/// `--auth native --no-cache`: full native handshake, no cache.
ColdNative,
/// Cached attach-ticket fast path (requires a warmed cache).
CachedTicket,
/// UDP resume fast path (requires a warmed cache + `cache_attach_tickets = false`).
Resume,
/// `--local-auth`: self-contained local bootstrap, no SSH.
LocalAuth,
/// SSH-bootstrap cold attach (cache controlled by `--no-cache` / `--warm-cache`).
SshBootstrap,
}
impl DoshPath {
fn metric(self) -> &'static str {
match self {
DoshPath::ColdNative => "dosh_cold_native_ms",
DoshPath::CachedTicket => "dosh_cached_attach_ms",
DoshPath::Resume => "dosh_resume_ms",
DoshPath::LocalAuth => "dosh_local_attach_ms",
DoshPath::SshBootstrap => "dosh_attach_ms",
}
}
/// Whether this path consumes a warmed credential cache.
fn needs_warm(self) -> bool {
matches!(self, DoshPath::CachedTicket | DoshPath::Resume)
}
}
fn main() -> Result<()> {
let args = Args::parse();
let client = args.client.clone().unwrap_or_else(default_client_path);
let mut ssh_times = Vec::new();
let mut dosh_times = Vec::new();
let mut mosh_times = Vec::new();
if args.no_cache && args.warm_cache {
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
}
let generated_control_path = if args.controlmaster {
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
} else {
@@ -78,96 +134,115 @@ fn main() -> Result<()> {
} else {
None
};
if args.no_cache && args.warm_cache {
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
}
let dosh_label = if args.warm_cache {
let _ = time_dosh_attach(&client, &args, control_path)?;
"dosh_cached_attach_ms"
// Resolve which Dosh paths to benchmark. Explicit path flags select an
// explicit matrix; otherwise fall back to the legacy single-path behavior
// so existing callers (CI docker scripts) keep working unchanged.
let explicit_paths = explicit_dosh_paths(&args);
let dosh_paths = if explicit_paths.is_empty() {
vec![legacy_dosh_path(&args)]
} else {
"dosh_attach_ms"
explicit_paths
};
for _ in 0..args.iterations.max(1) {
if !args.local_auth && !args.skip_ssh_baseline {
let mut results: Vec<MetricSamples> = Vec::new();
// SSH baseline (shared across the matrix; the comparison is per-iteration
// ssh-true vs the dosh path startup that replaces it).
let run_ssh = !args.local_auth && !args.skip_ssh_baseline && !dosh_only(&dosh_paths);
if run_ssh {
let mut ssh_times = Vec::new();
for _ in 0..args.iterations.max(1) {
let mut ssh = Command::new("ssh");
add_ssh_options(&mut ssh, &args, control_path);
ssh.arg(&args.server).arg("true");
ssh_times.push(time_command(&mut ssh)?);
}
results.push(MetricSamples::new("ssh_true_ms", ssh_times));
}
dosh_times.push(time_dosh_attach(&client, &args, control_path)?);
for path in &dosh_paths {
if path.needs_warm() {
// Prime the cache with one attach so the fast path has credentials.
let _ = time_dosh_attach(&client, &args, *path, control_path, true)?;
}
let mut samples = Vec::new();
for _ in 0..args.iterations.max(1) {
samples.push(time_dosh_attach(
&client,
&args,
*path,
control_path,
false,
)?);
}
results.push(MetricSamples::new(path.metric(), samples));
}
if args.include_mosh {
if args.include_mosh {
let mut mosh_times = Vec::new();
for _ in 0..args.iterations.max(1) {
mosh_times.push(time_mosh_in_pty(&args)?);
}
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
}
if !ssh_times.is_empty() {
println!(
"ssh_true_ms avg={:.2} samples={:?}",
avg_ms(&ssh_times),
ssh_times
);
}
println!(
"{dosh_label} avg={:.2} samples={:?}",
avg_ms(&dosh_times),
dosh_times
);
if !mosh_times.is_empty() {
println!(
"mosh_start_true_ms avg={:.2} samples={:?}",
avg_ms(&mosh_times),
mosh_times
);
}
if let Some(margin) = args.assert_ssh_plus_ms {
if ssh_times.is_empty() {
return Err(anyhow!(
"--assert-ssh-plus-ms requires non-local SSH benchmark"
));
}
let ssh_avg = avg_ms(&ssh_times);
let dosh_avg = avg_ms(&dosh_times);
if dosh_avg > ssh_avg + margin {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms exceeded ssh avg {ssh_avg:.2}ms + {margin:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms");
}
if let Some(margin) = args.assert_mosh_minus_ms {
if mosh_times.is_empty() {
return Err(anyhow!(
"--assert-mosh-minus-ms requires --include-mosh benchmark"
));
}
let dosh_avg = avg_ms(&dosh_times);
let mosh_avg = avg_ms(&mosh_times);
if dosh_avg + margin > mosh_avg {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh_avg:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms + {margin:.2}ms <= mosh avg {mosh_avg:.2}ms");
}
if let Some(max_ms) = args.assert_dosh_max_ms {
let dosh_avg = avg_ms(&dosh_times);
if dosh_avg > max_ms {
return Err(anyhow!(
"{dosh_label} avg {dosh_avg:.2}ms exceeded max {max_ms:.2}ms"
));
}
println!("gate ok: {dosh_label} avg {dosh_avg:.2}ms <= {max_ms:.2}ms");
}
let report = if args.json {
render_json(&args, &results)
} else if args.markdown {
render_markdown(&args, &results)
} else {
render_table(&args, &results)
};
write_report(&args, &report)?;
run_assertions(&args, &results)?;
Ok(())
}
/// Paths explicitly requested via flags.
fn explicit_dosh_paths(args: &Args) -> Vec<DoshPath> {
let mut paths = Vec::new();
if args.cold_native {
paths.push(DoshPath::ColdNative);
}
if args.cached_ticket {
paths.push(DoshPath::CachedTicket);
}
if args.resume {
paths.push(DoshPath::Resume);
}
paths
}
/// The single path implied by legacy flags when no explicit path is requested.
fn legacy_dosh_path(args: &Args) -> DoshPath {
if args.local_auth {
DoshPath::LocalAuth
} else if args.warm_cache {
DoshPath::CachedTicket
} else {
DoshPath::SshBootstrap
}
}
/// True when none of the selected paths needs an SSH baseline comparison
/// (i.e. all are local-auth or cache fast paths driven without SSH).
fn dosh_only(paths: &[DoshPath]) -> bool {
paths.iter().all(|p| {
matches!(
p,
DoshPath::LocalAuth | DoshPath::CachedTicket | DoshPath::Resume
)
})
}
fn time_dosh_attach(
client: &PathBuf,
args: &Args,
path: DoshPath,
control_path: Option<&PathBuf>,
warm: bool,
) -> Result<Duration> {
let mut cmd = Command::new(client);
cmd.arg("--attach-only")
@@ -178,30 +253,66 @@ fn time_dosh_attach(
if let Some(host) = &args.dosh_host {
cmd.arg("--dosh-host").arg(host);
}
if args.local_auth {
cmd.arg("--local-auth").arg(&args.server);
} else {
cmd.arg("--ssh-port")
.arg(args.ssh_port.to_string())
.arg("--ssh-auth-command")
.arg(&args.ssh_auth_command);
if args.no_cache {
match path {
DoshPath::LocalAuth => {
cmd.arg("--local-auth");
// While warming, write the cache; measured runs honor --no-cache.
if args.no_cache && !warm {
cmd.arg("--no-cache");
}
cmd.arg(&args.server);
}
DoshPath::CachedTicket | DoshPath::Resume => {
// Fast paths must read the warmed cache, so never pass --no-cache here.
// Whether the cache uses tickets vs resume is decided by the client
// config (`cache_attach_tickets`) the harness wrote into HOME.
if args.local_auth {
cmd.arg("--local-auth");
}
add_bootstrap_args(&mut cmd, args, control_path);
cmd.arg(&args.server);
}
DoshPath::ColdNative => {
cmd.arg("--auth").arg("native");
// Cold path: never use a warmed cache.
cmd.arg("--no-cache");
add_bootstrap_args(&mut cmd, args, control_path);
cmd.arg(&args.server);
}
if let Some(key) = &args.ssh_key {
cmd.arg("--ssh-key").arg(key);
DoshPath::SshBootstrap => {
add_bootstrap_args(&mut cmd, args, control_path);
// Cold SSH bootstrap honors --no-cache on measured runs.
if args.no_cache && !warm {
cmd.arg("--no-cache");
}
cmd.arg(&args.server);
}
if let Some(known_hosts) = &args.ssh_known_hosts {
cmd.arg("--ssh-known-hosts").arg(known_hosts);
}
if let Some(control_path) = control_path {
cmd.arg("--ssh-control-path").arg(control_path);
}
cmd.arg(&args.server);
}
time_command(&mut cmd)
}
/// Append the SSH bootstrap arguments shared by the non-local paths. Native
/// auth reuses the same SSH key/known-hosts/port plumbing.
fn add_bootstrap_args(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
if args.local_auth {
return;
}
cmd.arg("--ssh-port")
.arg(args.ssh_port.to_string())
.arg("--ssh-auth-command")
.arg(&args.ssh_auth_command);
if let Some(key) = &args.ssh_key {
cmd.arg("--ssh-key").arg(key);
}
if let Some(known_hosts) = &args.ssh_known_hosts {
cmd.arg("--ssh-known-hosts").arg(known_hosts);
}
if let Some(control_path) = control_path {
cmd.arg("--ssh-control-path").arg(control_path);
}
}
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
cmd.arg("-p").arg(args.ssh_port.to_string()).arg("-T");
if let Some(key) = &args.ssh_key {
@@ -381,9 +492,245 @@ fn time_command(cmd: &mut Command) -> Result<Duration> {
Ok(start.elapsed())
}
fn avg_ms(samples: &[Duration]) -> f64 {
let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
total / samples.len() as f64
/// Per-metric samples with summary statistics.
struct MetricSamples {
name: &'static str,
samples: Vec<Duration>,
}
impl MetricSamples {
fn new(name: &'static str, samples: Vec<Duration>) -> Self {
Self { name, samples }
}
fn ms(&self) -> Vec<f64> {
self.samples
.iter()
.map(|d| d.as_secs_f64() * 1000.0)
.collect()
}
fn stats(&self) -> Stats {
Stats::from_ms(&self.ms())
}
}
/// Summary statistics for a set of millisecond samples.
struct Stats {
count: usize,
min: f64,
median: f64,
p95: f64,
mean: f64,
max: f64,
}
impl Stats {
fn from_ms(values: &[f64]) -> Self {
if values.is_empty() {
return Self {
count: 0,
min: 0.0,
median: 0.0,
p95: 0.0,
mean: 0.0,
max: 0.0,
};
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let count = sorted.len();
let mean = sorted.iter().sum::<f64>() / count as f64;
Self {
count,
min: sorted[0],
median: percentile(&sorted, 50.0),
p95: percentile(&sorted, 95.0),
mean,
max: sorted[count - 1],
}
}
}
/// Linear-interpolated percentile over a pre-sorted slice.
fn percentile(sorted: &[f64], pct: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = rank - lo as f64;
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}
}
fn render_table(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
if let Some(label) = &args.label {
let _ = writeln!(out, "# label: {label}");
}
let _ = writeln!(
out,
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max"
);
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"{:<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();
let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", "));
}
out
}
fn render_json(args: &Args, results: &[MetricSamples]) -> String {
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(),
};
format!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1),
entries.join(",")
)
}
fn render_markdown(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
let label = args.label.as_deref().unwrap_or("Dosh benchmark");
let _ = writeln!(out, "# {label}\n");
let _ = writeln!(out, "- server: `{}`", args.server);
let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1));
let _ = writeln!(
out,
"- generated_by: `dosh-bench {}`\n",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"| metric | n | min ms | median ms | p95 ms | mean ms | max ms |"
);
let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|");
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
let _ = writeln!(out, "\n## Raw Samples\n");
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", "));
}
out
}
fn write_report(args: &Args, report: &str) -> Result<()> {
if let Some(path) = &args.output {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(path, report).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
} else {
println!("{report}");
}
Ok(())
}
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 {
@@ -392,3 +739,69 @@ fn default_client_path() -> PathBuf {
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("dosh-client"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args_for_render() -> Args {
Args {
server: "local".to_string(),
session: "default".to_string(),
ssh_port: 22,
dosh_port: 50000,
dosh_host: None,
iterations: 2,
local_auth: true,
cold_native: false,
cached_ticket: false,
resume: false,
client: None,
ssh_auth_command: "~/.local/bin/dosh-auth".to_string(),
ssh_key: None,
ssh_known_hosts: None,
ssh_control_path: None,
controlmaster: false,
no_cache: false,
warm_cache: false,
skip_ssh_baseline: true,
include_mosh: false,
mosh: PathBuf::from("mosh"),
mosh_server_command: "mosh-server".to_string(),
mosh_port: None,
json: false,
markdown: true,
output: None,
label: Some("test host".to_string()),
assert_ssh_plus_ms: None,
assert_mosh_minus_ms: None,
assert_dosh_max_ms: None,
}
}
#[test]
fn markdown_report_contains_summary_and_samples() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_cached_attach_ms",
vec![Duration::from_millis(3), Duration::from_millis(5)],
)];
let report = render_markdown(&args, &metrics);
assert!(report.contains("# test host"));
assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00"));
assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]"));
}
#[test]
fn json_report_contains_metrics() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_local_attach_ms",
vec![Duration::from_millis(7)],
)];
let report = render_json(&args, &metrics);
assert!(report.contains("\"label\":\"test host\""));
assert!(report.contains("\"metric\":\"dosh_local_attach_ms\""));
assert!(report.contains("\"samples_ms\":[7.0000]"));
}
}
+2169 -134
View File
File diff suppressed because it is too large Load Diff
+1656 -258
View File
File diff suppressed because it is too large Load Diff
+74 -2
View File
@@ -28,6 +28,14 @@ pub struct ServerConfig {
pub authorized_keys: Vec<String>,
#[serde(default = "default_native_auth_rate_limit_per_minute")]
pub native_auth_rate_limit_per_minute: u32,
/// Rotate a client's transport traffic key after this many packets in the
/// current epoch (spec §11). `0` disables the packet-count trigger.
#[serde(default = "default_rekey_after_packets")]
pub rekey_after_packets: u64,
/// Rotate a client's transport traffic key after this many wall-clock seconds
/// in the current epoch (spec §11). `0` disables the time trigger.
#[serde(default = "default_rekey_after_secs")]
pub rekey_after_secs: u64,
#[serde(default = "default_true")]
pub allow_tcp_forwarding: bool,
#[serde(default)]
@@ -38,6 +46,17 @@ pub struct ServerConfig {
pub allow_agent_forwarding: bool,
#[serde(default = "default_accept_env")]
pub accept_env: Vec<String>,
/// Run each terminal session's shell under a small per-session *holder*
/// process so it survives a `dosh-server` restart (crash/upgrade/
/// `systemctl restart`). On startup the server re-adopts live holders and
/// reattaching clients land on the same shell with screen state intact. When
/// `false`, sessions behave exactly as before: the shell is a child of the
/// server and dies with it.
// Opt-in for now: this changes the core session model (per-session holder
// processes + fd passing), so it stays OFF by default until it has been
// stress-tested on a real host. Set `persist_sessions = true` to enable.
#[serde(default)]
pub persist_sessions: bool,
}
impl Default for ServerConfig {
@@ -61,11 +80,14 @@ impl Default for ServerConfig {
host_key: default_host_key(),
authorized_keys: default_authorized_keys(),
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
rekey_after_packets: default_rekey_after_packets(),
rekey_after_secs: default_rekey_after_secs(),
allow_tcp_forwarding: true,
allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false,
accept_env: default_accept_env(),
persist_sessions: false,
}
}
}
@@ -82,8 +104,12 @@ pub struct ClientConfig {
pub default_session: String,
pub reconnect_timeout_secs: u64,
pub view_only: bool,
#[serde(default)]
#[serde(default = "default_true")]
pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown.
#[serde(default = "default_predict_mode")]
pub predict_mode: String,
pub cache_attach_tickets: bool,
pub credential_cache: String,
#[serde(default = "default_auth_preference")]
@@ -100,10 +126,21 @@ pub struct ClientConfig {
pub use_ssh_agent: bool,
#[serde(default)]
pub forward_agent: bool,
/// Show a non-destructive, mosh-style status line on the bottom screen row
/// when UDP packets stop arriving ("[dosh] last contact Ns ago …"). Drawn
/// with save/restore cursor so it never moves the app cursor or corrupts a
/// full-screen TUI, and cleared the instant packets resume. Default on.
#[serde(default = "default_true")]
pub disconnect_status: bool,
#[serde(default = "default_send_env")]
pub send_env: Vec<String>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Optional client-side command extensions. These are just shell command
/// templates expanded into the remote session's startup input; Dosh does not
/// depend on the tools they name.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -119,6 +156,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Per-host extension overrides. A host can replace a global extension or set
/// `disabled = true` to opt out of it.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandExtension {
/// Remote shell command template. `{args}` expands to shell-quoted extra
/// words after the extension name. When absent, extra args are appended.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -140,7 +193,8 @@ impl Default for ClientConfig {
default_session: "new".to_string(),
reconnect_timeout_secs: 5,
view_only: false,
predict: false,
predict: true,
predict_mode: default_predict_mode(),
cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(),
auth_preference: default_auth_preference(),
@@ -150,8 +204,10 @@ impl Default for ClientConfig {
identity_files: default_identity_files(),
use_ssh_agent: true,
forward_agent: false,
disconnect_status: true,
send_env: default_send_env(),
set_env: HashMap::new(),
extensions: HashMap::new(),
}
}
}
@@ -175,6 +231,18 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
30
}
fn default_rekey_after_packets() -> u64 {
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
// keeps forward-secrecy windows small.
100_000
}
fn default_rekey_after_secs() -> u64 {
// One hour per epoch by default.
3600
}
fn default_auth_preference() -> String {
"native,ssh".to_string()
}
@@ -183,6 +251,10 @@ fn default_native_auth_timeout_ms() -> u64 {
700
}
fn default_predict_mode() -> String {
"experimental".to_string()
}
fn default_known_hosts() -> String {
"~/.config/dosh/known_hosts".to_string()
}
+1
View File
@@ -2,6 +2,7 @@ pub mod auth;
pub mod config;
pub mod crypto;
pub mod native;
pub mod persist;
pub mod protocol;
pub mod pty;
pub mod ssh_agent;
+439 -47
View File
@@ -5,7 +5,9 @@ use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs;
use std::io::Write;
use std::net::IpAddr;
@@ -15,7 +17,7 @@ use std::str::FromStr;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
pub const NATIVE_PROTOCOL_VERSION: u8 = 1;
pub const NATIVE_PROTOCOL_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostPublicKey {
@@ -91,6 +93,12 @@ pub enum ForwardingKind {
Local,
Remote,
Dynamic,
/// SSH-agent forwarding: the client opts in and, if the server policy allows
/// (`allow_agent_forwarding`), the server exports a proxy unix socket as
/// `SSH_AUTH_SOCK` in the remote session and tunnels each connection back to
/// the client's local agent over a Dosh stream. Added at the end of the enum
/// so bincode discriminants for the existing variants are unchanged.
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -248,17 +256,55 @@ pub fn sign_server_hello(
Ok(())
}
/// Error raised when a native handshake peer speaks a different protocol version.
///
/// Carries the peer's advertised version so callers can render an actionable,
/// named message ("upgrade dosh") rather than letting a mismatch surface as an
/// opaque decrypt failure or a silent timeout. The `Display` text deliberately
/// embeds [`crate::protocol::VERSION_MISMATCH_REASON`] so the server's reject and
/// the client's local error read the same way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolVersionMismatch {
pub local: u8,
pub remote: u8,
pub peer: &'static str,
}
impl std::fmt::Display for ProtocolVersionMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({} speaks native protocol v{}, this build speaks v{})",
crate::protocol::VERSION_MISMATCH_REASON,
self.peer,
self.remote,
self.local,
)
}
}
impl std::error::Error for ProtocolVersionMismatch {}
/// Verify a peer-advertised native protocol version against this build.
///
/// `peer` names whose version was wrong ("client" or "server") for the error
/// message. Returns a typed [`ProtocolVersionMismatch`] so the caller can both
/// match on it and print an actionable, upgrade-oriented message.
pub fn check_native_protocol_version(version: u8, peer: &'static str) -> Result<()> {
if version != NATIVE_PROTOCOL_VERSION {
return Err(ProtocolVersionMismatch {
local: NATIVE_PROTOCOL_VERSION,
remote: version,
peer,
}
.into());
}
Ok(())
}
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
anyhow::ensure!(
client.protocol_version == NATIVE_PROTOCOL_VERSION,
"unsupported native client protocol {}",
client.protocol_version
);
anyhow::ensure!(
server.protocol_version == NATIVE_PROTOCOL_VERSION,
"unsupported native server protocol {}",
server.protocol_version
);
check_native_protocol_version(client.protocol_version, "client")?;
check_native_protocol_version(server.protocol_version, "server")?;
let transcript = server_hello_transcript(client, server)?;
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
}
@@ -295,6 +341,92 @@ pub fn sign_user_auth(
Ok(auth)
}
pub fn sign_user_auth_with_private_key(
private_key: &ssh_key::PrivateKey,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
let public_key = private_key.public_key();
let key_algorithm = public_key.algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&key_algorithm),
"unsupported native identity key algorithm {key_algorithm}"
);
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
} else {
public_key_blob
};
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
let mut auth = NativeUserAuth {
public_key_algorithm: signature_algorithm.to_string(),
public_key: public_key_for_auth,
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = if key_algorithm == "ssh-rsa" {
sign_rsa_sha512_private_key(private_key, &transcript)?
} else {
let signature = SignatureSigner::try_sign(private_key, &transcript)
.context("sign native user auth with OpenSSH private key")?;
anyhow::ensure!(
signature.algorithm().as_str() == auth.public_key_algorithm,
"private key produced signature algorithm {}, expected {}",
signature.algorithm().as_str(),
auth.public_key_algorithm
);
signature.as_bytes().to_vec()
};
Ok(auth)
}
fn sign_rsa_sha512_private_key(
private_key: &ssh_key::PrivateKey,
transcript: &[u8],
) -> Result<Vec<u8>> {
let rsa_keypair = private_key
.key_data()
.rsa()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
Ok(signature.to_vec())
}
fn rsa_private_key_from_ssh_keypair(
keypair: &ssh_key::private::RsaKeypair,
) -> Result<rsa::RsaPrivateKey> {
let private = rsa::RsaPrivateKey::from_components(
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
vec![
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
],
)
.context("convert OpenSSH RSA private key")?;
anyhow::ensure!(
private.size().saturating_mul(8) >= 2048,
"OpenSSH RSA identity is smaller than 2048 bits"
);
Ok(private)
}
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
match key_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"ssh-rsa" => Ok("rsa-sha2-512"),
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
}
}
pub fn verify_native_user_auth(
client: &NativeClientHello,
server: &NativeServerHello,
@@ -303,37 +435,127 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> {
anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519",
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}",
auth.public_key_algorithm
);
anyhow::ensure!(
auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes"
);
anyhow::ensure!(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key);
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?;
verifying_key
.verify(&transcript, &signature)
.context("verify native user signature")?;
verify_native_user_signature(auth, &transcript)?;
Ok(authorized.clone())
}
pub fn supported_user_key_algorithms() -> Vec<String> {
vec![
"ssh-ed25519".to_string(),
"ecdsa-sha2-nistp256".to_string(),
"rsa-sha2-512".to_string(),
"rsa-sha2-256".to_string(),
]
}
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
}
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
)
}
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
match signature_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
}
}
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
match auth.public_key_algorithm.as_str() {
"ssh-ed25519" => {
anyhow::ensure!(
auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes"
);
anyhow::ensure!(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key);
let verifying_key =
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
verifying_key
.verify(transcript, &signature)
.context("verify native user signature")?;
}
"ecdsa-sha2-nistp256" => {
let public_key = ssh_public_key_from_blob(&auth.public_key)
.context("parse native user SSH public key")?;
let algorithm =
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
format!("parse signature algorithm {}", auth.public_key_algorithm)
})?;
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
.context("parse native user SSH signature")?;
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
.context("verify native user SSH signature")?;
}
"rsa-sha2-512" | "rsa-sha2-256" => {
verify_rsa_sha2_signature(
&auth.public_key,
&auth.public_key_algorithm,
transcript,
&auth.signature,
)?;
}
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
}
Ok(())
}
fn verify_rsa_sha2_signature(
public_key_blob: &[u8],
signature_algorithm: &str,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
let public = rsa::RsaPublicKey::new(
rsa::BigUint::from_bytes_be(n),
rsa::BigUint::from_bytes_be(e),
)
.context("parse RSA public key")?;
let signature =
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
match signature_algorithm {
"rsa-sha2-256" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA256 signature")
}
"rsa-sha2-512" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA512 signature")
}
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
}
}
pub fn verify_native_user_auth_from_config(
config: &ServerConfig,
client: &NativeClientHello,
@@ -369,6 +591,36 @@ pub fn derive_native_session_key(
crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1")
}
/// Derive a rotated transport key for transport rekey (spec §11 / §9).
///
/// The rotated key is derived **independently of the handshake traffic keys**:
/// the input keying material is the current epoch's key (which both peers already
/// hold) mixed with fresh, server-generated `rekey_material` (32 bytes of CSPRNG
/// output, delivered confidentially inside an AEAD `Rekey` packet sealed under
/// the current key). The new `epoch` and the previous epoch's `session_key_id`
/// salt the derivation so each epoch's key is unique. It never re-derives from
/// the handshake DH output, satisfying the spec requirement that "rotated session
/// keys must be derived independently ... from fresh randomness" and "must not
/// reuse handshake traffic keys."
///
/// Both peers run this identically — the client never needs the server's
/// long-term secret, only the fresh `rekey_material` it receives in the `Rekey`
/// packet plus the current key it already shares.
pub fn derive_rekey_session_key(
current_key: &[u8; 32],
rekey_material: &[u8; 32],
previous_session_key_id: &[u8; 16],
epoch: u64,
) -> Result<[u8; 32]> {
let mut ikm = Vec::with_capacity(64);
ikm.extend_from_slice(current_key);
ikm.extend_from_slice(rekey_material);
let mut salt = b"dosh/native/rekey/v1".to_vec();
salt.extend_from_slice(previous_session_key_id);
salt.extend_from_slice(&epoch.to_be_bytes());
crypto::hkdf32(&ikm, &salt, b"dosh/native/rekey/chacha20poly1305/v1")
}
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
load_ed25519_identity_with_passphrase(path, None)
}
@@ -396,6 +648,35 @@ pub fn load_ed25519_identity_with_passphrase(
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
}
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
load_native_identity_with_passphrase(path, None)
}
pub fn load_native_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<ssh_key::PrivateKey> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let algorithm = private_key.public_key().algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&algorithm),
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
path.display()
);
Ok(private_key)
}
impl AuthorizedKey {
fn ensure_native_allowed(
&self,
@@ -445,6 +726,10 @@ impl AuthorizedKey {
"authorized key permitopen= cannot authorize remote or dynamic forwarding"
);
}
// Agent forwarding does not open a host:port, so permitopen
// (which restricts which targets may be opened) does not apply;
// it is gated separately by the server's allow_agent_forwarding.
ForwardingKind::Agent => {}
}
}
}
@@ -487,7 +772,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields"
);
let (options, key_type_index) = if fields[0].starts_with("ssh-") {
let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
(AuthorizedKeyOptions::default(), 0)
} else {
(parse_authorized_key_options(&fields[0])?, 1)
@@ -498,15 +783,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields
.get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
anyhow::ensure!(
algorithm == "ssh-ed25519",
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
let decoded = STANDARD
.decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
anyhow::ensure!(
is_supported_user_key_algorithm(algorithm),
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
validate_supported_public_key_blob(algorithm, &decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
let key = if algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
} else {
decoded
};
let comment = if fields.len() > key_type_index + 2 {
Some(fields[key_type_index + 2..].join(" "))
} else {
@@ -520,6 +810,51 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
})
}
fn validate_supported_public_key_blob(algorithm: &str, blob: &[u8]) -> Result<()> {
let blob_algorithm = ssh_public_key_blob_algorithm(blob)?;
anyhow::ensure!(
blob_algorithm == algorithm,
"key blob type {blob_algorithm} does not match authorized_keys type {algorithm}"
);
match algorithm {
"ssh-ed25519" => {
parse_ssh_ed25519_public_blob(blob)?;
}
"ecdsa-sha2-nistp256" | "ssh-rsa" => {
let _ = ssh_public_key_from_blob(blob)?;
}
_ => bail!("unsupported key type {algorithm}"),
}
Ok(())
}
fn ssh_public_key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
fn ssh_public_key_from_blob(blob: &[u8]) -> Result<ssh_key::PublicKey> {
let algorithm = ssh_public_key_blob_algorithm(blob)?;
let encoded = STANDARD.encode(blob);
ssh_key::PublicKey::from_openssh(&format!("{algorithm} {encoded}"))
.with_context(|| format!("parse OpenSSH public key blob {algorithm}"))
}
fn ssh_public_blob_from_public_key(public_key: &ssh_key::PublicKey) -> Result<Vec<u8>> {
let line = public_key
.to_openssh()
.context("encode OpenSSH public key")?;
let mut fields = line.split_whitespace();
let _algorithm = fields
.next()
.context("encoded public key missing algorithm")?;
let encoded = fields.next().context("encoded public key missing blob")?;
STANDARD
.decode(encoded)
.context("decode encoded OpenSSH public key blob")
}
fn split_authorized_key_fields(line: &str) -> Vec<String> {
line.split_whitespace().map(ToString::to_string).collect()
}
@@ -687,6 +1022,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
Ok(out)
}
fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
let e = read_ssh_mpint(&mut cursor)?;
let n = read_ssh_mpint(&mut cursor)?;
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
Ok((e, n))
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519");
@@ -694,6 +1041,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
out
}
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let raw = read_ssh_string(cursor)?;
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
Ok(raw)
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -926,16 +1279,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted
);
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted
);
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err());
assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(),
trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted
);
}
@@ -948,16 +1301,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown
);
trust_host(&path, "palav", &first, "ssh", false).unwrap();
trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted
);
assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(),
verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. }
));
}
@@ -1006,6 +1359,45 @@ mod tests {
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
}
#[test]
fn native_user_auth_accepts_ecdsa_p256_private_key() {
let mut rng = rand::rngs::OsRng;
let private = ssh_key::PrivateKey::random(
&mut rng,
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
)
.unwrap();
let host_signing = SigningKey::from_bytes(&[10u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_accepts_rsa_sha2_private_key() {
let mut rng = rand::rngs::OsRng;
let private =
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
let host_signing = SigningKey::from_bytes(&[11u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_rejects_tampered_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
@@ -1233,8 +1625,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
+658
View File
@@ -0,0 +1,658 @@
//! Session persistence across server restarts.
//!
//! A persistent dosh session's shell does not run as a child of `dosh-server`.
//! Instead the server spawns a tiny per-session *holder* process (the same
//! `dosh-server` binary re-exec'd as `dosh-server hold ...`). The holder calls
//! `setsid()` to leave the server's process group/session, opens a PTY, spawns
//! the shell as ITS own child, and listens on a Unix socket in a per-session
//! runtime directory. The server connects to that socket and the holder hands it
//! the PTY master fd over `SCM_RIGHTS`.
//!
//! Because the shell belongs to the holder (which is in its own session and is
//! not waited on by the server), killing or restarting `dosh-server` leaves the
//! holder + shell alive. On startup the server scans the runtime directory,
//! reconnects to each live holder, receives the master fd again, and rebuilds the
//! in-memory session so clients reattach to the very same shell.
//!
//! Screen / scrollback state lives only in server memory, so it is also mirrored
//! to disk (atomically) and restored on re-adoption, letting a reattaching client
//! repaint the screen exactly as it was before the restart.
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// One-byte commands a server sends to a holder over its control socket after
/// the holder has handed back the master fd.
const HOLDER_CMD_SHUTDOWN: u8 = b'X';
/// Magic written into a holder's `meta` file, bumped if the on-disk layout
/// changes so a stale holder from an incompatible build is ignored.
const META_MAGIC: &str = "dosh-holder-1";
/// Per-session runtime metadata persisted next to the holder socket.
#[derive(Debug, Clone)]
pub struct HolderMeta {
pub session: String,
pub shell_pid: i32,
}
/// Root runtime directory holding one subdirectory per persistent session.
/// Lives under `sessions_dir/run` so it shares the session storage location and
/// can be wiped with it.
pub fn runtime_root(sessions_dir: &Path) -> PathBuf {
sessions_dir.join("run")
}
/// Map a session name to a filesystem-safe directory name. Session names are
/// user-controlled, so hex-encode them rather than trusting them as path
/// components (avoids traversal, slashes, NULs, length issues).
fn session_dir_name(session: &str) -> String {
let mut out = String::with_capacity(session.len() * 2);
for byte in session.as_bytes() {
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
out.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
}
out
}
fn decode_session_dir_name(name: &str) -> Option<String> {
if !name.len().is_multiple_of(2) {
return None;
}
let mut bytes = Vec::with_capacity(name.len() / 2);
let raw = name.as_bytes();
let mut i = 0;
while i < raw.len() {
let hi = (raw[i] as char).to_digit(16)?;
let lo = (raw[i + 1] as char).to_digit(16)?;
bytes.push(((hi << 4) | lo) as u8);
i += 2;
}
String::from_utf8(bytes).ok()
}
/// Directory for one session's holder runtime state.
pub fn session_runtime_dir(sessions_dir: &Path, session: &str) -> PathBuf {
runtime_root(sessions_dir).join(session_dir_name(session))
}
fn holder_sock_path(dir: &Path) -> PathBuf {
dir.join("holder.sock")
}
fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta")
}
fn screen_path(dir: &Path) -> PathBuf {
dir.join("screen")
}
/// Create the runtime directory tree with private (0700) permissions.
pub fn ensure_runtime_dir(sessions_dir: &Path, session: &str) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let root = runtime_root(sessions_dir);
std::fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));
let dir = session_runtime_dir(sessions_dir, session);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod {}", dir.display()))?;
Ok(dir)
}
/// Atomically write `data` to `path` (write temp + rename), so a concurrent
/// reader (e.g. a restarting server) never sees a half-written file.
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = path.with_extension("tmp");
{
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
file.write_all(data)
.with_context(|| format!("write {}", tmp.display()))?;
file.sync_all().ok();
}
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Persist the vt100 screen snapshot plus recent raw output for a session so a
/// post-restart reattach repaints correctly. `snapshot` is the bytes a fresh
/// attach would receive (alt-screen toggle + `state_formatted`).
pub fn save_screen(
sessions_dir: &Path,
session: &str,
cols: u16,
rows: u16,
output_seq: u64,
snapshot: &[u8],
) -> Result<()> {
let dir = session_runtime_dir(sessions_dir, session);
if !dir.exists() {
// No holder runtime for this session (non-persistent): nothing to do.
return Ok(());
}
let mut buf = Vec::with_capacity(snapshot.len() + 32);
buf.extend_from_slice(&cols.to_be_bytes());
buf.extend_from_slice(&rows.to_be_bytes());
buf.extend_from_slice(&output_seq.to_be_bytes());
buf.extend_from_slice(&(snapshot.len() as u32).to_be_bytes());
buf.extend_from_slice(snapshot);
atomic_write(&screen_path(&dir), &buf)
}
/// A restored screen for a re-adopted session.
pub struct SavedScreen {
pub cols: u16,
pub rows: u16,
pub output_seq: u64,
pub snapshot: Vec<u8>,
}
/// Load a previously persisted screen, if any.
pub fn load_screen(sessions_dir: &Path, session: &str) -> Option<SavedScreen> {
let dir = session_runtime_dir(sessions_dir, session);
let data = std::fs::read(screen_path(&dir)).ok()?;
if data.len() < 16 {
return None;
}
let cols = u16::from_be_bytes(data[0..2].try_into().ok()?);
let rows = u16::from_be_bytes(data[2..4].try_into().ok()?);
let output_seq = u64::from_be_bytes(data[4..12].try_into().ok()?);
let len = u32::from_be_bytes(data[12..16].try_into().ok()?) as usize;
if data.len() < 16 + len {
return None;
}
Some(SavedScreen {
cols,
rows,
output_seq,
snapshot: data[16..16 + len].to_vec(),
})
}
fn write_meta(dir: &Path, meta: &HolderMeta) -> Result<()> {
let body = format!("{META_MAGIC}\n{}\n{}\n", meta.shell_pid, meta.session);
atomic_write(&meta_path(dir), body.as_bytes())
}
fn read_meta(dir: &Path) -> Option<HolderMeta> {
let body = std::fs::read_to_string(meta_path(dir)).ok()?;
let mut lines = body.lines();
if lines.next()? != META_MAGIC {
return None;
}
let shell_pid: i32 = lines.next()?.parse().ok()?;
let session = lines.next()?.to_string();
Some(HolderMeta { session, shell_pid })
}
/// Spawn a holder process for `session`: re-exec this binary as `dosh-server
/// hold` with the runtime dir, shell, terminal size, and accepted env. The
/// holder daemonizes (setsid, owns the PTY) and listens on its control socket.
/// Returns once the holder has signalled readiness by creating its socket.
#[allow(clippy::too_many_arguments)]
pub fn spawn_holder(
sessions_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
let dir = ensure_runtime_dir(sessions_dir, session)?;
let sock = holder_sock_path(&dir);
// Clear any stale socket left by a crashed holder with the same name.
let _ = std::fs::remove_file(&sock);
let exe = std::env::current_exe().context("locate current executable")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("hold")
.arg("--runtime-dir")
.arg(&dir)
.arg("--session")
.arg(session)
.arg("--shell")
.arg(shell)
.arg("--cols")
.arg(cols.to_string())
.arg("--rows")
.arg(rows.to_string());
for (name, value) in env {
cmd.arg("--env").arg(format!("{name}={value}"));
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let mut child = cmd.spawn().context("spawn holder process")?;
// Wait (briefly) for the holder to come up. The holder forks/setsids before
// creating the socket; once the socket exists we can connect. We also reap
// the short-lived launcher child so it never becomes a zombie.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if sock.exists() {
break;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("holder for session {session} did not come up in time");
}
std::thread::sleep(Duration::from_millis(20));
}
// The launcher exits immediately after the grandchild setsids; reap it.
let _ = child.wait();
Ok(())
}
/// Connect to a session's holder and receive the PTY master fd over SCM_RIGHTS.
/// Returns the raw fd (caller owns it) and the holder control socket, kept open
/// so the server can later ask the holder to shut down. Returns an error if the
/// holder is gone (caller then degrades to a fresh, non-persistent session).
pub fn adopt_holder(sessions_dir: &Path, session: &str) -> Result<(RawFd, UnixStream)> {
let dir = session_runtime_dir(sessions_dir, session);
let sock = holder_sock_path(&dir);
let mut stream = UnixStream::connect(&sock)
.with_context(|| format!("connect holder socket {}", sock.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let fd = recv_fd(&mut stream).context("receive master fd from holder")?;
Ok((fd, stream))
}
/// Ask a holder to terminate its shell and exit, then clean its runtime dir.
/// Used when reaping a truly-abandoned persistent session.
pub fn request_shutdown(sessions_dir: &Path, session: &str, control: Option<&mut UnixStream>) {
if let Some(stream) = control {
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
} else {
let dir = session_runtime_dir(sessions_dir, session);
if let Ok(mut stream) = UnixStream::connect(holder_sock_path(&dir)) {
// Drain the fd the holder sends on connect, then send shutdown.
let _ = recv_fd(&mut stream);
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
}
}
// Give the holder a moment to tear down, then remove its runtime dir.
std::thread::sleep(Duration::from_millis(50));
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Remove a session's runtime directory unconditionally (best effort).
pub fn remove_runtime_dir(sessions_dir: &Path, session: &str) {
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Scan the runtime root for holders left behind by a previous server. Returns
/// `(session_name, meta)` for each one whose holder process still appears alive.
/// Stale entries (no live process) are cleaned up.
pub fn scan_existing_holders(sessions_dir: &Path) -> Vec<HolderMeta> {
let root = runtime_root(sessions_dir);
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(&root) else {
return found;
};
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let Some(name) = dir.file_name().and_then(|n| n.to_str()) else {
continue;
};
// Decode the on-disk name back to a session name; skip junk dirs.
if decode_session_dir_name(name).is_none() {
continue;
}
match read_meta(&dir) {
Some(meta) if process_alive(meta.shell_pid) && holder_sock_path(&dir).exists() => {
found.push(meta);
}
_ => {
// Holder gone or meta unreadable: clean up so we don't try to
// adopt a dead session (degrade to fresh on next attach).
let _ = std::fs::remove_dir_all(&dir);
}
}
}
found
}
/// Whether a pid refers to a live process (signal 0 probe).
fn process_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
// ---------------------------------------------------------------------------
// Holder process entry point
// ---------------------------------------------------------------------------
/// Run as a holder process. Daemonizes (double-fork + setsid), opens a PTY,
/// spawns the shell as its own child, and serves the control socket: every
/// accepted connection is handed the master fd (SCM_RIGHTS); a subsequent
/// SHUTDOWN byte (or the shell exiting) tears everything down.
///
/// This function does not return on success — it `exit`s the process. Errors
/// before the daemonization point are returned to the launcher.
#[allow(clippy::too_many_arguments)]
pub fn run_holder(
runtime_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
use portable_pty::{NativePtySystem, PtySize, PtySystem};
// Detach from the launching server: own session + process group so a server
// exit (even a process-group kill of the service) does not take us down.
daemonize().context("daemonize holder")?;
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("holder open pty")?;
let cmd = crate::pty::build_shell_command(shell, env);
let mut child = pair
.slave
.spawn_command(cmd)
.context("holder spawn shell")?;
drop(pair.slave);
let master_fd = pair
.master
.as_raw_fd()
.ok_or_else(|| anyhow!("holder master has no raw fd"))?;
let shell_pid = child.process_id().map(|p| p as i32).unwrap_or(-1);
write_meta(
runtime_dir,
&HolderMeta {
session: session.to_string(),
shell_pid,
},
)?;
let sock = holder_sock_path(runtime_dir);
let _ = std::fs::remove_file(&sock);
let listener =
UnixListener::bind(&sock).with_context(|| format!("holder bind {}", sock.display()))?;
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600));
}
listener
.set_nonblocking(false)
.context("holder listener blocking")?;
// A watcher thread reaps the shell: when it exits, the holder cleans up and
// exits too, so an abandoned shell does not linger forever.
let runtime_owned = runtime_dir.to_path_buf();
std::thread::spawn(move || {
let _ = child.wait();
// Shell exited: remove runtime dir and exit the holder process.
let _ = std::fs::remove_dir_all(&runtime_owned);
std::process::exit(0);
});
// Accept loop: each connecting server gets the master fd; a SHUTDOWN byte
// from any of them tears the holder down. Multiple servers never run at once
// in practice (one service), but serving repeated connections lets a server
// restart re-adopt cleanly.
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
if send_fd(&mut stream, master_fd).is_err() {
continue;
}
// Wait for an optional command byte. EOF (server dropped the control
// socket on its own exit) just means "keep running, await re-adoption".
let mut cmd = [0u8; 1];
match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0);
}
_ => {
// Server detached (exit/restart) or sent nothing actionable:
// loop back and wait for the next server to re-adopt us.
}
}
}
Ok(())
}
/// Double-fork + `setsid` so the holder runs in its own session, detached from
/// the server's controlling terminal and process group. Without this a
/// `systemctl restart` (which signals the whole service cgroup/process group)
/// could take the holder down with the server.
fn daemonize() -> Result<()> {
// First fork: parent (launcher) returns to reap; child continues.
match unsafe { libc::fork() } {
-1 => bail!("fork: {}", std::io::Error::last_os_error()),
0 => {}
_ => {
// Parent process: exit so the launcher's wait() returns promptly and
// the grandchild is reparented to init.
std::process::exit(0);
}
}
// New session: detaches from controlling tty and the server's process group.
if unsafe { libc::setsid() } == -1 {
bail!("setsid: {}", std::io::Error::last_os_error());
}
// Second fork: ensures we are not a session leader, so we can never
// re-acquire a controlling terminal.
match unsafe { libc::fork() } {
-1 => bail!("fork2: {}", std::io::Error::last_os_error()),
0 => Ok(()),
_ => std::process::exit(0),
}
}
// ---------------------------------------------------------------------------
// SCM_RIGHTS file-descriptor passing over a Unix domain socket
// ---------------------------------------------------------------------------
/// Send a single fd over `stream` using SCM_RIGHTS, with one byte of normal data
/// (sendmsg requires at least one iovec byte for the ancillary data to ride on).
fn send_fd(stream: &mut UnixStream, fd: RawFd) -> Result<()> {
let dummy: [u8; 1] = [0];
let mut iov = libc::iovec {
iov_base: dummy.as_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("CMSG_FIRSTHDR null");
}
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
std::ptr::copy_nonoverlapping(
&fd as *const RawFd as *const u8,
libc::CMSG_DATA(cmsg),
std::mem::size_of::<RawFd>(),
);
let n = libc::sendmsg(stream.as_raw_fd(), &msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("sendmsg SCM_RIGHTS");
}
}
Ok(())
}
/// Receive a single fd sent via SCM_RIGHTS. Returns a fresh fd owned by the
/// caller.
fn recv_fd(stream: &mut UnixStream) -> Result<RawFd> {
let mut dummy = [0u8; 1];
let mut iov = libc::iovec {
iov_base: dummy.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let n = libc::recvmsg(stream.as_raw_fd(), &mut msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("recvmsg SCM_RIGHTS");
}
if n == 0 {
bail!("holder closed connection before sending fd");
}
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("no ancillary data (fd) received from holder");
}
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
bail!("unexpected ancillary message from holder");
}
let mut fd: RawFd = -1;
std::ptr::copy_nonoverlapping(
libc::CMSG_DATA(cmsg),
&mut fd as *mut RawFd as *mut u8,
std::mem::size_of::<RawFd>(),
);
if fd < 0 {
bail!("invalid fd received from holder");
}
Ok(fd)
}
}
/// Space, in bytes, needed for a control-message buffer carrying exactly one fd.
const fn cmsg_space_one_fd() -> usize {
// CMSG_SPACE is not const in libc; this is the equivalent for one RawFd.
// cmsghdr is aligned to size_of::<usize>(); add data length rounded up.
let data = std::mem::size_of::<RawFd>();
let hdr = std::mem::size_of::<libc::cmsghdr>();
let align = std::mem::size_of::<usize>();
// round(hdr) + round(data)
((hdr + align - 1) & !(align - 1)) + ((data + align - 1) & !(align - 1))
}
/// Helper used by `adopt_holder` callers to turn the received raw fd into an
/// owned `File`-like object if they need RAII (the server hands it to
/// `pty::adopt_pty_from_fd`, which takes ownership of the raw fd).
pub fn fd_into_file(fd: RawFd) -> std::fs::File {
unsafe { std::fs::File::from_raw_fd(fd) }
}
/// Convert a `File` back into a raw fd it no longer owns (so it can be handed to
/// `adopt_pty_from_fd`). Currently unused outside tests but kept symmetric.
pub fn file_into_fd(file: std::fs::File) -> RawFd {
file.into_raw_fd()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_dir_name_round_trips() {
for name in ["default", "work", "a/b/../c", "weird name", "日本語"] {
let encoded = session_dir_name(name);
assert!(encoded.bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(decode_session_dir_name(&encoded).as_deref(), Some(name));
}
}
#[test]
fn decode_rejects_non_hex() {
assert!(decode_session_dir_name("zz").is_none());
assert!(decode_session_dir_name("abc").is_none());
}
#[test]
fn save_and_load_screen_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
ensure_runtime_dir(sessions_dir, "work").unwrap();
let snap = b"\x1b[?1049lhello world".to_vec();
save_screen(sessions_dir, "work", 100, 40, 7, &snap).unwrap();
let loaded = load_screen(sessions_dir, "work").expect("screen restored");
assert_eq!(loaded.cols, 100);
assert_eq!(loaded.rows, 40);
assert_eq!(loaded.output_seq, 7);
assert_eq!(loaded.snapshot, snap);
}
#[test]
fn load_screen_absent_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(load_screen(tmp.path(), "missing").is_none());
}
#[test]
fn scan_skips_dead_and_junk_entries() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
// A meta pointing at a definitely-dead pid is cleaned up, not returned.
let dir = ensure_runtime_dir(sessions_dir, "ghost").unwrap();
write_meta(
&dir,
&HolderMeta {
session: "ghost".to_string(),
shell_pid: 2_000_000_000, // not a live pid
},
)
.unwrap();
// A junk (non-hex) directory is ignored.
std::fs::create_dir_all(runtime_root(sessions_dir).join("not-hex")).unwrap();
let found = scan_existing_holders(sessions_dir);
assert!(found.is_empty(), "dead/junk holders must be skipped");
assert!(!dir.exists(), "dead holder dir should be cleaned up");
}
#[test]
fn send_and_recv_fd_round_trips() {
use std::io::Seek;
// Pass a temp file's fd across a socketpair and confirm both ends point
// at the same open file (write via one, read via the other).
let (mut a, mut b) = UnixStream::pair().unwrap();
let mut file = tempfile::tempfile().unwrap();
writeln!(file, "shared-fd-marker").unwrap();
file.flush().unwrap();
send_fd(&mut a, file.as_raw_fd()).unwrap();
let received = recv_fd(&mut b).unwrap();
let mut got = fd_into_file(received);
got.rewind().unwrap();
let mut contents = String::new();
got.read_to_string(&mut contents).unwrap();
assert!(contents.contains("shared-fd-marker"));
}
}
+124 -2
View File
@@ -3,10 +3,56 @@ use crate::crypto;
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Generate a name for an implicit, ephemeral terminal session — the kind
/// created by `dosh host` with no `--session`. Single source of truth shared by
/// the client (which generates it) and the server (which recognizes it via
/// [`is_implicit_session_name`] to decide a session is NOT worth persisting).
pub fn generate_implicit_session_name() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("term-{millis}-{}", std::process::id())
}
/// Whether `name` is a client-generated implicit session name
/// (`term-<digits>-<digits>`). Such sessions are ephemeral: the user can never
/// reattach by name, so the server must not persist them across restarts.
/// Explicitly-named (`--session work`) and prewarmed sessions are not implicit.
pub fn is_implicit_session_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("term-") else {
return false;
};
let mut parts = rest.split('-');
match (parts.next(), parts.next(), parts.next()) {
(Some(millis), Some(pid), None) => {
!millis.is_empty()
&& millis.bytes().all(|b| b.is_ascii_digit())
&& !pid.is_empty()
&& pid.bytes().all(|b| b.is_ascii_digit())
}
_ => false,
}
}
pub const MAGIC: &[u8; 4] = b"DOSH";
pub const VERSION: u8 = 1;
// v4: added reliable stream offsets/acks to `StreamData` and
// `StreamWindowAdjust`.
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
// deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 4;
pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
/// native handshake arrives carrying a `protocol_version` it cannot speak. The
/// client recognizes this prefix and surfaces a clear "upgrade dosh" message
/// instead of the generic transport rejection or, worse, a silent timeout.
pub const VERSION_MISMATCH_REASON: &str = "protocol version mismatch — upgrade dosh";
const HEADER_AAD_LEN: usize = HEADER_LEN - 2;
pub const CLIENT_TO_SERVER: u32 = 1;
pub const SERVER_TO_CLIENT: u32 = 2;
@@ -39,6 +85,8 @@ pub enum PacketKind {
StreamEof = 23,
StreamClose = 24,
NativeAuthCheckOk = 25,
Rekey = 26,
RekeyAck = 27,
}
impl TryFrom<u8> for PacketKind {
@@ -71,6 +119,8 @@ impl TryFrom<u8> for PacketKind {
23 => Self::StreamEof,
24 => Self::StreamClose,
25 => Self::NativeAuthCheckOk,
26 => Self::Rekey,
27 => Self::RekeyAck,
_ => bail!("unknown packet kind {value}"),
})
}
@@ -110,7 +160,11 @@ impl Header {
bail!("bad magic");
}
if input[4] != VERSION {
bail!("bad protocol version {}", input[4]);
bail!(
"{} (peer wire protocol v{}, this build speaks v{VERSION})",
VERSION_MISMATCH_REASON,
input[4]
);
}
let kind = PacketKind::try_from(input[5])?;
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
@@ -133,6 +187,20 @@ impl Header {
}
}
/// Cheaply inspect a datagram that carries our [`MAGIC`] but whose wire
/// [`VERSION`] byte differs from this build's. Returns the peer's advertised
/// wire version when (and only when) the packet is a Dosh packet we cannot
/// otherwise decode because of a version skew, so the receiver can answer with a
/// clear, named version-mismatch reject instead of dropping it (a silent
/// timeout for the peer). Returns `None` for our own version, foreign magic, or
/// runt packets.
pub fn peek_foreign_wire_version(input: &[u8]) -> Option<u8> {
if input.len() < 5 || &input[..4] != MAGIC || input[4] == VERSION {
return None;
}
Some(input[4])
}
#[derive(Debug, Clone)]
pub struct Packet {
pub header: Header,
@@ -350,12 +418,14 @@ pub struct StreamOpenReject {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamData {
pub stream_id: u64,
pub offset: u64,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamWindowAdjust {
pub stream_id: u64,
pub received_offset: u64,
pub bytes: u32,
}
@@ -369,6 +439,20 @@ pub struct StreamClose {
pub stream_id: u64,
}
/// Server→client transport rekey, sealed under the *current* session key.
///
/// Carries the fresh server-generated `rekey_material` and the new `epoch`; both
/// peers feed these into [`crate::native::derive_rekey_session_key`] to compute
/// the next traffic key. `new_session_key_id` is the id the next epoch's packets
/// will carry, so the client can recognize and switch atomically. The client
/// replies with a [`PacketKind::RekeyAck`] encrypted under the *new* key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rekey {
pub epoch: u64,
pub rekey_material: [u8; 32],
pub new_session_key_id: [u8; 16],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frame {
pub session: String,
@@ -449,3 +533,41 @@ impl ReplayWindow {
}
}
}
#[cfg(test)]
mod session_name_tests {
use super::{generate_implicit_session_name, is_implicit_session_name};
#[test]
fn generated_names_are_recognized_as_implicit() {
let name = generate_implicit_session_name();
assert!(is_implicit_session_name(&name), "generated: {name}");
}
#[test]
fn explicit_and_prewarm_names_are_not_implicit() {
for name in [
"default",
"work",
"logs",
"term",
"term-",
"term-abc-1",
"term-1-x",
"term-1",
"term-1-2-3",
"",
] {
assert!(
!is_implicit_session_name(name),
"should not be implicit: {name:?}"
);
}
}
#[test]
fn implicit_shape_matches() {
assert!(is_implicit_session_name("term-1781470634216-76685"));
assert!(is_implicit_session_name("term-0-0"));
}
}
+221 -23
View File
@@ -1,14 +1,42 @@
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::path::Path;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use tokio::sync::mpsc;
// Keep live terminal output comfortably below common path MTUs after Dosh's
// protocol header, AEAD tag, UDP/IP headers, and bincode framing. Full-screen
// TUIs often write several KiB on the first draw; sending that as one UDP
// datagram can fragment and vanish, leaving only a blank alternate screen.
const PTY_OUTPUT_CHUNK_BYTES: usize = 1024;
/// Backing for a PTY master held by the server.
///
/// `Owned` means this process spawned the shell as a child and is responsible
/// for it: dropping the handle kills the shell. This is the original,
/// non-persistent model and stays the default.
///
/// `Adopted` means the shell lives in a separate holder process and this handle
/// only borrows the master fd (received over a Unix socket via SCM_RIGHTS).
/// Dropping it must NOT kill the shell — it just closes our copy of the fd and
/// stops the reader thread, leaving the holder + shell alive so a server restart
/// can re-adopt them.
enum Backing {
Owned {
child: Mutex<Box<dyn Child + Send + Sync>>,
_master: Box<dyn MasterPty + Send>,
},
Adopted {
master: Mutex<std::fs::File>,
},
}
pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
_master: Box<dyn MasterPty + Send>,
backing: Backing,
}
impl PtyHandle {
@@ -20,14 +48,69 @@ impl PtyHandle {
}
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self._master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
match &self.backing {
Backing::Owned { _master, .. } => {
_master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
}
Backing::Adopted { master } => {
let file = master.lock().expect("pty master poisoned");
resize_fd(file.as_raw_fd(), cols, rows)?;
}
}
Ok(())
}
/// True for a handle backed by a separate holder process. Such a handle must
/// be detached (not killed) when the server lets go of a session, so the
/// shell survives a server restart.
pub fn is_persistent(&self) -> bool {
matches!(self.backing, Backing::Adopted { .. })
}
/// Terminate the shell process backing this PTY and reap it.
///
/// Only meaningful for an `Owned` backing (the server spawned the shell as a
/// child). For an `Adopted` backing the shell belongs to the holder process,
/// so this is a no-op here; reaping a persistent session is done by asking
/// the holder to shut down (see `persist::request_shutdown`).
pub fn kill(&self) {
if let Backing::Owned { child, .. } = &self.backing
&& let Ok(mut child) = child.lock()
{
let _ = child.kill();
let _ = child.wait();
}
}
}
impl Drop for PtyHandle {
fn drop(&mut self) {
// Adopted handles must NOT kill the shell: it lives in the holder so it
// can outlive this server. Dropping just closes our fd / stops the
// reader. Owned handles keep the original kill-on-drop behavior.
if let Backing::Owned { .. } = &self.backing {
self.kill();
}
}
}
fn resize_fd(fd: RawFd, cols: u16, rows: u16) -> Result<()> {
let winsize = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("TIOCSWINSZ");
}
Ok(())
}
#[derive(Debug)]
@@ -54,12 +137,82 @@ pub fn spawn_pty_session(
pixel_height: 0,
})
.context("open pty")?;
let cmd = build_shell_command(shell, env);
let child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
let writer = pair.master.take_writer().context("take pty writer")?;
let reader = pair.master.try_clone_reader().context("clone pty reader")?;
spawn_reader_thread(session, reader, tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
backing: Backing::Owned {
child: Mutex::new(child),
_master: pair.master,
},
})
}
/// Whether this host has a terminfo entry for `term`, searching the standard
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
/// hashed (`78/xterm`) subdirectory layouts are checked.
fn terminfo_available(term: &str) -> bool {
let Some(first) = term.chars().next() else {
return false;
};
let letter = first.to_string();
let hashed = format!("{:x}", first as u32);
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(t) = std::env::var("TERMINFO")
&& !t.is_empty()
{
dirs.push(PathBuf::from(t));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".terminfo"));
}
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
for d in td.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d));
}
}
for d in [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
] {
dirs.push(PathBuf::from(d));
}
dirs.iter()
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
}
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's
/// environment matches a non-persistent one.
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
for (name, value) in env {
cmd.env(name, value);
}
// The client's TERM is propagated, but a server that lacks that terminal's
// terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like
// tmux/vim with "missing or unsuitable terminal". Keep the requested TERM
// only when this host actually has its terminfo; otherwise fall back to a
// universally available entry so remote apps always work.
let term = match env
.iter()
.find(|(n, _)| n == "TERM")
.map(|(_, v)| v.as_str())
{
Some(requested) if terminfo_available(requested) => requested.to_string(),
_ => "xterm-256color".to_string(),
};
cmd.env("TERM", term);
cmd.env("SHELL", shell);
if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str());
@@ -68,11 +221,39 @@ pub fn spawn_pty_session(
} else if let Some(parent) = Path::new(shell).parent() {
cmd.env("PWD", parent.as_os_str());
}
let _child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
cmd
}
let writer = pair.master.take_writer().context("take pty writer")?;
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
/// Build a [`PtyHandle`] from a master fd received from a holder process.
///
/// `master_fd` is an fd this handle takes ownership of (it is wrapped in a
/// `File` and closed on drop). The shell is NOT a child of this process; it
/// belongs to the holder, so dropping this handle leaves it running.
pub fn adopt_pty_from_fd(
session: String,
master_fd: RawFd,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<PtyHandle> {
// Take ownership of the fd. A clone gives us an independent reader so the
// reader thread and the writer/resize side hold separate `File`s and don't
// get closed out from under each other.
let master = unsafe { std::fs::File::from_raw_fd(master_fd) };
let reader_file = master.try_clone().context("clone master for reader")?;
let writer_file = master.try_clone().context("clone master for writer")?;
spawn_reader_thread(session, Box::new(reader_file), tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(Box::new(writer_file))),
backing: Backing::Adopted {
master: Mutex::new(master),
},
})
}
fn spawn_reader_thread(
session: String,
mut reader: Box<dyn Read + Send>,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<()> {
let reader_session = session.clone();
thread::Builder::new()
.name(format!("dosh-pty-{session}"))
@@ -89,11 +270,13 @@ pub fn spawn_pty_session(
break;
}
Ok(n) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: buf[..n].to_vec(),
exited: false,
});
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: chunk.to_vec(),
exited: false,
});
}
}
Err(_) => {
let _ = tx.send(PtyOutput {
@@ -107,9 +290,24 @@ pub fn spawn_pty_session(
}
})
.context("spawn pty reader")?;
Ok(())
}
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
_master: pair.master,
})
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminfo_available_detects_known_and_unknown() {
// A near-universal entry should be present on any host with ncurses.
assert!(terminfo_available("xterm") || terminfo_available("xterm-256color"));
// Bogus / empty names must report missing so we fall back.
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
assert!(!terminfo_available(""));
}
#[test]
fn pty_output_chunk_size_stays_mtu_safe() {
assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
}
}
+61 -23
View File
@@ -1,6 +1,6 @@
use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
parse_ssh_ed25519_public_blob, user_auth_transcript,
is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
};
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
@@ -12,12 +12,16 @@ const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity {
pub key_blob: Vec<u8>,
pub public_key: [u8; 32],
pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub sign_algorithm: String,
pub sign_flags: u32,
pub comment: String,
}
@@ -38,13 +42,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?;
let identities = request_supported_identities(&mut agent)?;
let identity = identities
.first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?;
.ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(),
public_key: identity.public_key.to_vec(),
public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.clone(),
signature: Vec::new(),
requested_forwardings,
};
@@ -54,7 +58,7 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth)
}
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice();
@@ -72,18 +76,49 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) {
identities.push(AgentIdentity {
key_blob,
public_key,
comment,
});
if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(identity);
}
}
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities)
}
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
let algorithm = key_blob_algorithm(&key_blob)?;
if !is_supported_user_key_algorithm(&algorithm) {
return Ok(None);
}
let identity = match algorithm.as_str() {
"ssh-ed25519" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(),
sign_algorithm: "ssh-ed25519".to_string(),
sign_flags: 0,
key_blob,
comment,
},
"ecdsa-sha2-nistp256" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: key_blob.clone(),
sign_algorithm: algorithm,
sign_flags: 0,
key_blob,
comment,
},
"ssh-rsa" => AgentIdentity {
public_key_algorithm: algorithm,
public_key: key_blob.clone(),
sign_algorithm: "rsa-sha2-512".to_string(),
sign_flags: SSH_AGENT_RSA_SHA2_512,
key_blob,
comment,
},
_ => return Ok(None),
};
Ok(Some(identity))
}
fn sign_with_agent(
agent: &mut UnixStream,
identity: &AgentIdentity,
@@ -93,7 +128,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes());
request.extend_from_slice(&identity.sign_flags.to_be_bytes());
write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?;
@@ -114,20 +149,17 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!(
algorithm == b"ssh-ed25519",
"ssh-agent returned unsupported signature algorithm {}",
String::from_utf8_lossy(algorithm)
algorithm == identity.sign_algorithm,
"ssh-agent returned signature algorithm {algorithm}, expected {}",
identity.sign_algorithm
);
let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!(
signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob"
);
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec())
}
@@ -182,6 +214,12 @@ fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(value);
}
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -253,8 +291,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+415
View File
@@ -0,0 +1,415 @@
//! Parser robustness tests (Track B, spec milestone 5 / §16 "Fuzz packet parsing").
//!
//! These throw arbitrary/garbage bytes at every reachable public parser in the
//! `dosh` library and assert that NONE of them panic. A parser is allowed to
//! return `Ok` (if the bytes happened to be valid) or `Err`, but a panic on
//! untrusted input is a denial-of-service / robustness bug against a hostile
//! network attacker (threat model §5: "Active network attacker that can spoof
//! ... or modify packets").
//!
//! Determinism: a fixed-seed PRNG (`rand::rngs::StdRng`) is used so failures are
//! reproducible. No external dependencies beyond what is already in Cargo.toml.
use std::panic::{self, AssertUnwindSafe};
use dosh::auth::{
AttachTicketPlain, BootstrapResponse, SealedAttachTicket, decode_bootstrap, open_attach_ticket,
verify_attach_ticket,
};
use dosh::native::{
AuthorizedKey, HostPublicKey, KnownHost, NativeAuthOk, NativeClientHello, NativeServerHello,
NativeUserAuth, parse_authorized_keys, parse_host_public_key_line, parse_known_hosts,
parse_ssh_ed25519_public_blob, verify_known_host,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Header, Input,
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
NativeUserAuthBody, Packet, Resize, ResumeRequest, StreamClose, StreamData, StreamEof,
StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
TicketAttachEnvelope, TicketAttachOkEnvelope,
};
use rand::rngs::StdRng;
use rand::{Rng, RngCore, SeedableRng};
const ITERATIONS: usize = 4000;
/// Run `f` and convert a panic into a test failure with a descriptive message.
fn no_panic<F: FnOnce()>(label: &str, input: &[u8], f: F) {
let result = panic::catch_unwind(AssertUnwindSafe(f));
assert!(
result.is_ok(),
"parser `{label}` PANICKED on input ({} bytes): {:02x?}",
input.len(),
input,
);
}
/// Generate a variety of "interesting" byte buffers for a given iteration.
fn fuzz_bytes(rng: &mut StdRng) -> Vec<u8> {
let strategy = rng.gen_range(0..7u8);
match strategy {
0 => {
let len = rng.gen_range(0..1200);
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
1 => {
let len = rng.gen_range(0..16);
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
2 => {
let len = rng.gen_range(0..(protocol::HEADER_LEN + 64));
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
3 => vec![0u8; rng.gen_range(0..256)],
4 => vec![0xffu8; rng.gen_range(0..256)],
5 => {
// A valid-magic prefix followed by garbage to drive deeper paths.
let mut buf = Vec::new();
buf.extend_from_slice(protocol::MAGIC);
buf.push(protocol::VERSION);
let extra = rng.gen_range(0..256);
let mut tail = vec![0u8; extra];
rng.fill_bytes(&mut tail);
buf.extend_from_slice(&tail);
buf
}
_ => {
// Large length prefixes to provoke huge allocations / overflow in
// length fields (a classic deserialization hazard).
let mut buf = Vec::new();
buf.extend_from_slice(&u64::MAX.to_le_bytes());
let extra = rng.gen_range(0..64);
let mut tail = vec![0u8; extra];
rng.fill_bytes(&mut tail);
buf.extend_from_slice(&tail);
buf
}
}
}
/// Generate a possibly-valid UTF-8 string from random bytes (for text parsers).
fn fuzz_text(rng: &mut StdRng) -> String {
let len = rng.gen_range(0..256);
let mut s = String::new();
for _ in 0..len {
let pick = rng.gen_range(0..10u8);
let ch = match pick {
0 => ' ',
1 => '\n',
2 => '\t',
3 => '=',
4 => ',',
5 => '"',
6 => '/',
7 => rng.gen_range(b'a'..=b'z') as char,
8 => rng.gen_range(b'0'..=b'9') as char,
_ => char::from_u32(rng.gen_range(0..0x110000)).unwrap_or('?'),
};
s.push(ch);
}
s
}
#[test]
fn protocol_packet_decode_never_panics() {
let mut rng = StdRng::seed_from_u64(0xD05Au64);
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("protocol::decode", &input, || {
let _ = protocol::decode(&input);
});
no_panic("Header::parse", &input, || {
let _ = Header::parse(&input);
});
}
}
/// `from_body` deserializes a bincode body into each protocol/native struct.
/// On the wire this runs on attacker-controlled bytes, so it must never panic.
#[test]
fn protocol_from_body_never_panics() {
let mut rng = StdRng::seed_from_u64(0xBEEFu64);
macro_rules! body_target {
($input:expr, $ty:ty) => {{
let input = $input;
no_panic(concat!("from_body::<", stringify!($ty), ">"), input, || {
let _ = protocol::from_body::<$ty>(input);
});
}};
}
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
let input = input.as_slice();
// protocol.rs structs
body_target!(input, BootstrapAttachRequest);
body_target!(input, TicketAttachEnvelope);
body_target!(input, TicketAttachBody);
body_target!(input, TicketAttachOkEnvelope);
body_target!(input, AttachOk);
body_target!(input, AttachReject);
body_target!(input, ResumeRequest);
body_target!(input, Input);
body_target!(input, Resize);
body_target!(input, Frame);
body_target!(input, StreamOpen);
body_target!(input, StreamOpenOk);
body_target!(input, StreamOpenReject);
body_target!(input, StreamData);
body_target!(input, StreamWindowAdjust);
body_target!(input, StreamEof);
body_target!(input, StreamClose);
// native handshake wrapper bodies
body_target!(input, NativeClientHelloBody);
body_target!(input, NativeServerHelloBody);
body_target!(input, NativeUserAuthBody);
body_target!(input, NativeAuthOkBody);
body_target!(input, NativeAuthCheckOkBody);
// bare native handshake structs
body_target!(input, NativeClientHello);
body_target!(input, NativeServerHello);
body_target!(input, NativeUserAuth);
body_target!(input, NativeAuthOk);
body_target!(input, HostPublicKey);
// auth.rs structs (deserialized from untrusted material too)
body_target!(input, BootstrapResponse);
body_target!(input, SealedAttachTicket);
body_target!(input, AttachTicketPlain);
}
}
/// Full decode -> decrypt_body pipeline on garbage. decrypt should Err (not
/// panic) on bad ciphertext / wrong key id / truncated body.
#[test]
fn protocol_decode_then_decrypt_never_panics() {
let mut rng = StdRng::seed_from_u64(0x1234_5678u64);
let key = [7u8; 32];
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("decode+decrypt_body", &input, || {
if let Ok(packet) = protocol::decode(&input) {
let _ = protocol::decrypt_body(&packet, &key, protocol::CLIENT_TO_SERVER);
let _ = protocol::decrypt_body(&packet, &key, protocol::SERVER_TO_CLIENT);
}
});
}
}
/// Mutate a single byte of a valid encrypted packet; decode and decrypt must
/// not panic, and decryption of the mutated packet must fail (no double-apply).
#[test]
fn protocol_bit_flips_on_valid_packet_never_panic() {
let mut rng = StdRng::seed_from_u64(0x900Du64);
let key = [9u8; 32];
let conn_id = [3u8; 16];
for _ in 0..1000 {
let mut plaintext = vec![0u8; rng.gen_range(0..200)];
rng.fill_bytes(&mut plaintext);
let seq = rng.gen_range(1..u64::MAX);
let Ok(mut packet) = protocol::encode_encrypted(
protocol::PacketKind::Input,
conn_id,
seq,
0,
&key,
protocol::CLIENT_TO_SERVER,
&plaintext,
) else {
continue;
};
if packet.is_empty() {
continue;
}
let idx = rng.gen_range(0..packet.len());
packet[idx] ^= 1 << rng.gen_range(0..8);
no_panic("flip+decode+decrypt", &packet, || {
if let Ok(decoded) = protocol::decode(&packet) {
let _ = protocol::decrypt_body(&decoded, &key, protocol::CLIENT_TO_SERVER);
}
});
}
}
#[test]
fn ssh_ed25519_blob_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0x5511u64);
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("parse_ssh_ed25519_public_blob", &input, || {
let _ = parse_ssh_ed25519_public_blob(&input);
});
}
// Targeted: length prefixes that lie about the body length.
for bad_len in [0u32, 1, 31, 32, 33, u32::MAX, u32::MAX - 1] {
let mut buf = Vec::new();
buf.extend_from_slice(&bad_len.to_be_bytes());
buf.extend_from_slice(b"ssh-ed25519");
buf.extend_from_slice(&32u32.to_be_bytes());
buf.extend_from_slice(&[0u8; 16]);
no_panic("parse_ssh_ed25519_public_blob:lying-len", &buf, || {
let _ = parse_ssh_ed25519_public_blob(&buf);
});
}
}
#[test]
fn authorized_keys_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0xA011u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_authorized_keys", text.as_bytes(), || {
let _ = parse_authorized_keys(&text);
});
}
let crafted = [
"ssh-ed25519",
"ssh-ed25519 ",
"ssh-ed25519 not-base64!!!",
"from= ssh-ed25519 AAAA",
"from=\"unterminated ssh-ed25519 AAAA",
"command=\"x\\\" ssh-ed25519 AAAA",
"permitopen=,,, ssh-ed25519 AAAA",
"restrict,no-port-forwarding,from=\"127.0.0.1\" ssh-ed25519 AAAA comment",
"ssh-rsa AAAA",
"\u{0}\u{0}\u{0} ssh-ed25519 AAAA",
];
for line in crafted {
no_panic("parse_authorized_keys:crafted", line.as_bytes(), || {
let _ = parse_authorized_keys(line);
});
}
}
#[test]
fn known_hosts_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0xC051u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_known_hosts", text.as_bytes(), || {
let _ = parse_known_hosts(&text);
});
}
let crafted = [
"host",
"host dosh-ed25519",
"host dosh-ed25519 not-base64!!!",
"host wrong-algo AAAA",
"host dosh-ed25519 AAAA first-seen=notnum source=tofu",
"host dosh-ed25519 AAAA first-seen= source=",
"* dosh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
];
for line in crafted {
no_panic("parse_known_hosts:crafted", line.as_bytes(), || {
let _ = parse_known_hosts(line);
});
}
}
#[test]
fn host_public_key_line_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0x4002u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_host_public_key_line", text.as_bytes(), || {
let _ = parse_host_public_key_line(&text);
});
}
}
#[test]
fn decode_bootstrap_never_panics() {
let mut rng = StdRng::seed_from_u64(0xB007u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("decode_bootstrap", text.as_bytes(), || {
let _ = decode_bootstrap(&text);
});
// Also feed base64-shaped random for the decode path proper.
let raw = fuzz_bytes(&mut rng);
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&raw);
no_panic("decode_bootstrap:b64", b64.as_bytes(), || {
let _ = decode_bootstrap(&b64);
});
}
}
#[test]
fn attach_ticket_open_and_verify_never_panic() {
let mut rng = StdRng::seed_from_u64(0x7CE7u64);
let secret = [42u8; 32];
let psk = [11u8; 32];
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("open_attach_ticket", &input, || {
let _ = open_attach_ticket(&secret, &input);
});
no_panic("verify_attach_ticket", &input, || {
let _ = verify_attach_ticket(&secret, &input, &psk, "default", "read-write");
});
}
}
/// Throw garbage at the known-host verifier (file parse + host key compare).
#[test]
fn verify_known_host_with_garbage_keys_never_panics() {
let mut rng = StdRng::seed_from_u64(0x9090u64);
let dir = tempfile::tempdir().unwrap();
for _ in 0..500 {
let text = fuzz_text(&mut rng);
let path = dir.path().join("known_hosts");
std::fs::write(&path, &text).unwrap();
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let host = HostPublicKey {
algorithm: "dosh-ed25519".to_string(),
key: key_bytes,
};
let host_name = fuzz_text(&mut rng);
no_panic("verify_known_host", text.as_bytes(), || {
let _ = verify_known_host(&path, &host_name, &host);
});
}
}
/// Regression guard: valid inputs still parse, so the fuzz harness isn't
/// accidentally exercising a build where every path simply Errs.
#[test]
fn valid_inputs_still_parse() {
let key = [5u8; 32];
let blob = dosh::native::ssh_ed25519_public_blob(&key);
assert_eq!(parse_ssh_ed25519_public_blob(&blob).unwrap(), key);
let session_key = [1u8; 32];
let packet = protocol::encode_encrypted(
protocol::PacketKind::Input,
[2u8; 16],
1,
0,
&session_key,
protocol::CLIENT_TO_SERVER,
b"hello",
)
.unwrap();
let decoded: Packet = protocol::decode(&packet).unwrap();
let plain = protocol::decrypt_body(&decoded, &session_key, protocol::CLIENT_TO_SERVER).unwrap();
assert_eq!(plain, b"hello");
assert!(parse_authorized_keys("").unwrap().is_empty());
assert!(parse_known_hosts("# just a comment\n").unwrap().is_empty());
// Reference types only otherwise used in macro expansions / signatures.
let _ = std::mem::size_of::<AuthorizedKey>();
let _ = std::mem::size_of::<KnownHost>();
}
+133
View File
@@ -126,6 +126,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
);
}
#[test]
fn peek_foreign_wire_version_flags_only_version_skew() {
let key = crypto::random_32();
let mut packet = protocol::encode_encrypted(
PacketKind::Input,
crypto::random_16(),
1,
0,
&key,
CLIENT_TO_SERVER,
b"hi",
)
.unwrap();
// A correctly framed packet for this build is not "foreign".
assert_eq!(protocol::peek_foreign_wire_version(&packet), None);
// Bumping the wire version byte makes it undecodable but recognizable.
packet[4] = protocol::VERSION.wrapping_add(7);
assert_eq!(
protocol::peek_foreign_wire_version(&packet),
Some(protocol::VERSION.wrapping_add(7))
);
assert!(protocol::decode(&packet).is_err());
// Non-Dosh datagrams and runts are ignored.
assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None);
assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None);
}
#[test]
fn rekey_key_derivation_agrees_and_is_independent_per_epoch() {
use dosh::native::derive_rekey_session_key;
// The handshake/current key both peers already share.
let current_key = crypto::random_32();
let current_id = protocol::session_key_id(&current_key);
// Fresh server-generated material, delivered confidentially in the Rekey.
let material = crypto::random_32();
// Both peers derive identically from shared current key + shipped material.
let server_view = derive_rekey_session_key(&current_key, &material, &current_id, 1).unwrap();
let client_view = derive_rekey_session_key(&current_key, &material, &current_id, 1).unwrap();
assert_eq!(server_view, client_view);
// The rotated key must not equal the handshake/current key.
assert_ne!(server_view, current_key);
// A different epoch (or different material) yields a different key.
let next_epoch = derive_rekey_session_key(&current_key, &material, &current_id, 2).unwrap();
assert_ne!(server_view, next_epoch);
let other_material = crypto::random_32();
let other = derive_rekey_session_key(&current_key, &other_material, &current_id, 1).unwrap();
assert_ne!(server_view, other);
}
#[test]
fn rekey_round_trip_decrypts_old_and_new_epoch_packets() {
use dosh::native::derive_rekey_session_key;
let key_epoch0 = crypto::random_32();
let id0 = protocol::session_key_id(&key_epoch0);
let conn_id = crypto::random_16();
// Pre-rekey packet sealed under epoch-0 key.
let pre = protocol::encode_encrypted(
PacketKind::Frame,
conn_id,
5,
0,
&key_epoch0,
protocol::SERVER_TO_CLIENT,
b"before",
)
.unwrap();
// Rotate to epoch 1.
let material = crypto::random_32();
let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap();
let post = protocol::encode_encrypted(
PacketKind::Frame,
conn_id,
6,
0,
&key_epoch1,
protocol::SERVER_TO_CLIENT,
b"after",
)
.unwrap();
let pre = protocol::decode(&pre).unwrap();
let post = protocol::decode(&post).unwrap();
// Each epoch's key carries its own session_key_id; the receiver picks the
// right one and both decrypt correctly.
assert_eq!(pre.header.session_key_id, id0);
assert_eq!(
pre.header.session_key_id,
protocol::session_key_id(&key_epoch0)
);
assert_eq!(
post.header.session_key_id,
protocol::session_key_id(&key_epoch1)
);
assert_eq!(
protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(),
b"before"
);
assert_eq!(
protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(),
b"after"
);
// A stale-epoch packet under the wrong key is rejected via session_key_id
// BEFORE any AEAD work — ignorable, not a fatal decrypt error.
let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err();
assert!(err.to_string().contains("session key id"));
}
#[test]
fn check_native_protocol_version_names_the_mismatch() {
use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version};
check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap();
let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server")
.unwrap_err();
let message = err.to_string();
assert!(
message.contains(protocol::VERSION_MISMATCH_REASON),
"expected actionable upgrade message, got {message:?}"
);
assert!(
message.contains("server"),
"should name the wrong peer: {message:?}"
);
}
#[test]
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
let mut replay = ReplayWindow::new(8);