Compare commits
37
Commits
6c14d669b8
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
013d653f99 | ||
|
|
4e7e4cff10 | ||
|
|
fbad776441 | ||
|
|
90d9b583c0 | ||
|
|
f7c4ebaaf7 | ||
|
|
27419f4ca8 | ||
|
|
ec2422bc3e | ||
|
|
d51cc248e7 | ||
|
|
b44ff8e773 | ||
|
|
7884ea2796 | ||
|
|
774da7371e | ||
|
|
41cdb0f54f | ||
|
|
90e53f4b68 | ||
|
|
d0d6f59cdf | ||
|
|
25d9a6aefa | ||
|
|
2835da76b0 | ||
|
|
eec8ef0a02 | ||
|
|
41256b66b7 | ||
|
|
14b7e75025 | ||
|
|
8b1af51bc6 | ||
|
|
a88d912d2b | ||
|
|
683c3cd01d | ||
|
|
9a52d65beb | ||
|
|
6b2933eb05 | ||
|
|
8da98c45e7 | ||
|
|
2c7654138b | ||
|
|
f9c1973c13 | ||
|
|
1306adb558 | ||
|
|
1e5517dcf1 | ||
|
|
cdeba047bc | ||
|
|
c47ae98c68 | ||
|
|
c48f2280a0 | ||
|
|
6bded28d40 | ||
|
|
9b0f09a8a8 | ||
|
|
c37424eb45 | ||
|
|
828206f757 | ||
|
|
2a6f28d529 |
@@ -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
+5
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,54 @@ 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. 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:
|
||||
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 palav
|
||||
dosh --session logs palav
|
||||
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:
|
||||
|
||||
@@ -155,7 +171,31 @@ UDP host from an SSH alias when `dosh_host` is not configured. To make that expl
|
||||
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 +230,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 +244,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 +288,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 +329,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 +342,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.
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
/corpus
|
||||
/artifacts
|
||||
/coverage
|
||||
Cargo.lock
|
||||
@@ -0,0 +1,64 @@
|
||||
[package]
|
||||
name = "dosh-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
# Standalone workspace so this crate is never absorbed by, and never affects,
|
||||
# the main `dosh` crate's `cargo build` / `cargo test` / `cargo fmt --check`.
|
||||
[workspace]
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
|
||||
[dependencies.dosh]
|
||||
path = ".."
|
||||
|
||||
# cargo-fuzz needs unwinding to report panics; keep debug assertions on.
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
[[bin]]
|
||||
name = "packet_decode"
|
||||
path = "fuzz_targets/packet_decode.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "from_body"
|
||||
path = "fuzz_targets/from_body.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "authorized_keys"
|
||||
path = "fuzz_targets/authorized_keys.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "known_hosts"
|
||||
path = "fuzz_targets/known_hosts.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "handshake_structs"
|
||||
path = "fuzz_targets/handshake_structs.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "attach_ticket"
|
||||
path = "fuzz_targets/attach_ticket.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,20 @@
|
||||
#![no_main]
|
||||
//! Fuzz the attach-ticket and bootstrap decoders. These open server-sealed
|
||||
//! AEAD blobs and base64 bootstrap envelopes from cache / wire material that an
|
||||
//! attacker may corrupt; none may panic.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use dosh::auth::{decode_bootstrap, open_attach_ticket, verify_attach_ticket};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let secret = [0x11u8; 32];
|
||||
let psk = [0x22u8; 32];
|
||||
|
||||
let _ = open_attach_ticket(&secret, data);
|
||||
let _ = verify_attach_ticket(&secret, data, &psk, "default", "read-write");
|
||||
|
||||
if let Ok(text) = std::str::from_utf8(data) {
|
||||
let _ = decode_bootstrap(text);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
#![no_main]
|
||||
//! Fuzz the authorized_keys parser, including its option lexer and the
|
||||
//! ssh-ed25519 public-key blob parser it depends on. None may panic.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use dosh::native::{parse_authorized_keys, parse_ssh_ed25519_public_blob};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// The blob parser operates directly on raw bytes.
|
||||
let _ = parse_ssh_ed25519_public_blob(data);
|
||||
|
||||
// The line parser operates on text; only feed valid UTF-8 (lossless),
|
||||
// matching how the file is read in production via read_to_string.
|
||||
if let Ok(text) = std::str::from_utf8(data) {
|
||||
let _ = parse_authorized_keys(text);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
#![no_main]
|
||||
//! Fuzz `protocol::from_body` (bincode deserialization) for every protocol and
|
||||
//! native struct that is decoded from untrusted wire bytes. None may panic.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use dosh::auth::{AttachTicketPlain, BootstrapResponse, SealedAttachTicket};
|
||||
use dosh::native::{
|
||||
HostPublicKey, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth,
|
||||
};
|
||||
use dosh::protocol::{
|
||||
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Input, NativeAuthCheckOkBody,
|
||||
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, Resize,
|
||||
ResumeRequest, StreamClose, StreamData, StreamEof, StreamOpen, StreamOpenOk, StreamOpenReject,
|
||||
StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||
};
|
||||
|
||||
macro_rules! try_body {
|
||||
($data:expr, $ty:ty) => {
|
||||
let _ = protocol::from_body::<$ty>($data);
|
||||
};
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// protocol.rs structs
|
||||
try_body!(data, BootstrapAttachRequest);
|
||||
try_body!(data, TicketAttachEnvelope);
|
||||
try_body!(data, TicketAttachBody);
|
||||
try_body!(data, TicketAttachOkEnvelope);
|
||||
try_body!(data, AttachOk);
|
||||
try_body!(data, AttachReject);
|
||||
try_body!(data, ResumeRequest);
|
||||
try_body!(data, Input);
|
||||
try_body!(data, Resize);
|
||||
try_body!(data, Frame);
|
||||
try_body!(data, StreamOpen);
|
||||
try_body!(data, StreamOpenOk);
|
||||
try_body!(data, StreamOpenReject);
|
||||
try_body!(data, StreamData);
|
||||
try_body!(data, StreamWindowAdjust);
|
||||
try_body!(data, StreamEof);
|
||||
try_body!(data, StreamClose);
|
||||
|
||||
// native handshake wrapper bodies
|
||||
try_body!(data, NativeClientHelloBody);
|
||||
try_body!(data, NativeServerHelloBody);
|
||||
try_body!(data, NativeUserAuthBody);
|
||||
try_body!(data, NativeAuthOkBody);
|
||||
try_body!(data, NativeAuthCheckOkBody);
|
||||
|
||||
// bare native handshake structs
|
||||
try_body!(data, NativeClientHello);
|
||||
try_body!(data, NativeServerHello);
|
||||
try_body!(data, NativeUserAuth);
|
||||
try_body!(data, NativeAuthOk);
|
||||
try_body!(data, HostPublicKey);
|
||||
|
||||
// auth.rs structs
|
||||
try_body!(data, BootstrapResponse);
|
||||
try_body!(data, SealedAttachTicket);
|
||||
try_body!(data, AttachTicketPlain);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
#![no_main]
|
||||
//! Fuzz the native handshake structs and their structural verifiers.
|
||||
//!
|
||||
//! Beyond plain deserialization (covered by the `from_body` target), this drives
|
||||
//! the verifier state machine: if the input happens to decode into the handshake
|
||||
//! structs, run `verify_server_hello`, `user_auth_transcript`, and
|
||||
//! `verify_native_user_auth` on them. These run on attacker-controlled material
|
||||
//! during the handshake and must reject (Err) without panicking.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use dosh::native::{
|
||||
NativeClientHello, NativeServerHello, NativeUserAuth, user_auth_transcript,
|
||||
verify_native_user_auth, verify_server_hello,
|
||||
};
|
||||
use dosh::protocol;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// Split the input into three slices and try to decode each into a handshake
|
||||
// struct. Use a length prefix scheme that is robust to short inputs.
|
||||
if data.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let n = data.len();
|
||||
let a = n / 3;
|
||||
let b = 2 * n / 3;
|
||||
let (chunk_client, chunk_server, chunk_auth) = (&data[..a], &data[a..b], &data[b..]);
|
||||
|
||||
let client: Option<NativeClientHello> = protocol::from_body(chunk_client).ok();
|
||||
let server: Option<NativeServerHello> = protocol::from_body(chunk_server).ok();
|
||||
let auth: Option<NativeUserAuth> = protocol::from_body(chunk_auth).ok();
|
||||
|
||||
if let (Some(client), Some(server)) = (&client, &server) {
|
||||
// Host signature verification over the transcript must not panic.
|
||||
let _ = verify_server_hello(client, server);
|
||||
|
||||
if let Some(auth) = &auth {
|
||||
// Transcript construction and full user-auth verification (signature
|
||||
// check + authorized-key matching) must not panic on garbage.
|
||||
let _ = user_auth_transcript(client, server, auth);
|
||||
let _ = verify_native_user_auth(client, server, auth, &[], None);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
#![no_main]
|
||||
//! Fuzz the known_hosts parser and the host-public-key line parser. None may
|
||||
//! panic on arbitrary input.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use dosh::native::{parse_host_public_key_line, parse_known_hosts};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(text) = std::str::from_utf8(data) {
|
||||
let _ = parse_known_hosts(text);
|
||||
let _ = parse_host_public_key_line(text);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
#![no_main]
|
||||
//! Fuzz the wire-packet decoder + decrypt path.
|
||||
//!
|
||||
//! Mirrors tests/parser_robustness.rs but driven by libFuzzer so the coverage
|
||||
//! engine can search for panics in `protocol::decode`, `Header::parse`, and the
|
||||
//! decode -> decrypt_body pipeline. The objective is: NO PANICS on any input.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use dosh::protocol::{self, CLIENT_TO_SERVER, SERVER_TO_CLIENT};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// Header parsing must never panic.
|
||||
let _ = protocol::Header::parse(data);
|
||||
|
||||
// Full decode, then attempt decryption with a fixed key in both directions.
|
||||
// A real attacker controls these bytes; neither path may panic.
|
||||
if let Ok(packet) = protocol::decode(data) {
|
||||
let key = [0x42u8; 32];
|
||||
let _ = protocol::decrypt_body(&packet, &key, CLIENT_TO_SERVER);
|
||||
let _ = protocol::decrypt_body(&packet, &key, SERVER_TO_CLIENT);
|
||||
}
|
||||
});
|
||||
+132
-13
@@ -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,8 +23,110 @@ function Require-Command($Name) {
|
||||
}
|
||||
}
|
||||
|
||||
Require-Command cargo
|
||||
function Normalize-Arch {
|
||||
switch ($env:PROCESSOR_ARCHITECTURE) {
|
||||
"AMD64" { "x86_64"; break }
|
||||
"ARM64" { "aarch64"; break }
|
||||
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
|
||||
}
|
||||
}
|
||||
|
||||
function Release-ArtifactName {
|
||||
if ($BinaryName) {
|
||||
return $BinaryName
|
||||
}
|
||||
"dosh-windows-$(Normalize-Arch).zip"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
@@ -35,14 +143,30 @@ if (Test-Path "Cargo.toml") {
|
||||
try {
|
||||
Push-Location $src
|
||||
cargo build --release --bin dosh-client --bin dosh-bench
|
||||
|
||||
$bindir = Join-Path $Prefix "bin"
|
||||
$configDir = Join-Path $HOME ".config\dosh"
|
||||
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
||||
|
||||
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)) {
|
||||
@@ -60,6 +184,8 @@ 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
|
||||
@@ -78,10 +204,3 @@ credential_cache = "~/.local/share/dosh/credentials"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+227
-17
@@ -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,6 +137,170 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
@@ -173,13 +353,6 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
bindir="$prefix/bin"
|
||||
config_dir="$HOME/.config/dosh"
|
||||
data_dir="$HOME/.local/share/dosh"
|
||||
systemd_user_dir="$HOME/.config/systemd/user"
|
||||
|
||||
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
|
||||
@@ -189,6 +362,43 @@ 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
|
||||
server_config="$config_dir/server.toml"
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env sh
|
||||
# Safe, self-contained local Dosh benchmark harness.
|
||||
#
|
||||
# Spins up a THROWAWAY dosh-server bound to 127.0.0.1 on a random free port in a
|
||||
# temp HOME, runs the full path matrix (native cold auth, cached attach-ticket,
|
||||
# UDP resume, and local-auth), prints raw samples + summary stats, then tears
|
||||
# everything down.
|
||||
#
|
||||
# It NEVER touches the production server: it never uses UDP port 50000, never
|
||||
# restarts any systemd unit, and never reads or writes the real ~/.config/dosh
|
||||
# or ~/.local. Everything lives under a mktemp HOME that is removed on exit.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/bench-local.sh [ITERATIONS]
|
||||
# Environment:
|
||||
# DOSH_BENCH_ITERS iteration count (default 20; overridden by $1)
|
||||
# DOSH_BENCH_JSON=1 emit machine-readable JSON instead of the table
|
||||
# DOSH_BENCH_PROFILE release|debug build profile (default release)
|
||||
set -eu
|
||||
|
||||
iters="${1:-${DOSH_BENCH_ITERS:-20}}"
|
||||
profile="${DOSH_BENCH_PROFILE:-release}"
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
# Make sure cargo is reachable in non-login shells.
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
if [ "$profile" = "release" ]; then
|
||||
cargo build --release >&2
|
||||
bindir="$repo_root/target/release"
|
||||
else
|
||||
cargo build >&2
|
||||
bindir="$repo_root/target/debug"
|
||||
fi
|
||||
server_bin="$bindir/dosh-server"
|
||||
client_bin="$bindir/dosh-client"
|
||||
bench_bin="$bindir/dosh-bench"
|
||||
|
||||
# Pick a free UDP port that is NOT the production port (50000).
|
||||
free_udp_port() {
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()
|
||||
PY
|
||||
}
|
||||
dosh_port="$(free_udp_port)"
|
||||
if [ "$dosh_port" = "50000" ]; then
|
||||
dosh_port="$(free_udp_port)"
|
||||
fi
|
||||
if [ "$dosh_port" = "50000" ]; then
|
||||
echo "refusing to bind production port 50000" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
home="$(mktemp -d)"
|
||||
server_pid=""
|
||||
cleanup() {
|
||||
if [ -n "$server_pid" ]; then
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$home"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
mkdir -p "$home/.config/dosh" "$home/.ssh" "$home/.local/share/dosh"
|
||||
|
||||
# Client identity used by the native cold-auth path.
|
||||
ssh-keygen -t ed25519 -N "" -q -f "$home/.ssh/id_ed25519"
|
||||
client_pub="$(cat "$home/.ssh/id_ed25519.pub")"
|
||||
printf '%s\n' "$client_pub" > "$home/authorized_keys"
|
||||
|
||||
# Throwaway server config: localhost only, throwaway port, attach tickets on.
|
||||
cat > "$home/.config/dosh/server.toml" <<EOF
|
||||
port = $dosh_port
|
||||
bind = "127.0.0.1"
|
||||
scrollback = 5000
|
||||
auth_ttl_secs = 30
|
||||
attach_ticket_ttl_secs = 3600
|
||||
allow_attach_tickets = true
|
||||
client_timeout_secs = 60
|
||||
retransmit_window = 256
|
||||
default_input_mode = "read-write"
|
||||
prewarm_sessions = ["default"]
|
||||
create_on_attach = true
|
||||
shell = "/bin/sh"
|
||||
sessions_dir = "$home/sessions"
|
||||
secret_path = "$home/secret"
|
||||
host_key = "$home/host_key"
|
||||
authorized_keys = ["$home/authorized_keys"]
|
||||
EOF
|
||||
|
||||
# Client config: native auth, trust-on-first-use (no SSH needed), localhost UDP.
|
||||
write_client_config() {
|
||||
# $1 = cache_attach_tickets (true|false)
|
||||
cat > "$home/.config/dosh/client.toml" <<EOF
|
||||
server = "local"
|
||||
dosh_host = "127.0.0.1"
|
||||
dosh_port = $dosh_port
|
||||
default_session = "default"
|
||||
reconnect_timeout_secs = 5
|
||||
view_only = false
|
||||
predict = false
|
||||
cache_attach_tickets = $1
|
||||
credential_cache = "$home/.local/share/dosh/credentials"
|
||||
known_hosts = "$home/.config/dosh/known_hosts"
|
||||
auth_preference = "native"
|
||||
trust_on_first_use = true
|
||||
EOF
|
||||
}
|
||||
|
||||
start_server() {
|
||||
HOME="$home" "$server_bin" serve --config "$home/.config/dosh/server.toml" \
|
||||
>"$home/server.log" 2>&1 &
|
||||
server_pid="$!"
|
||||
# Wait for the UDP socket to come up.
|
||||
i=0
|
||||
while [ "$i" -lt 50 ]; do
|
||||
if grep -q "listening on" "$home/server.log" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$server_pid" 2>/dev/null; then
|
||||
echo "dosh-server exited early:" >&2
|
||||
cat "$home/server.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
run_bench() {
|
||||
# $@ extra args forwarded to dosh-bench
|
||||
json_flag=""
|
||||
[ "${DOSH_BENCH_JSON:-0}" = "1" ] && json_flag="--json"
|
||||
label="$(uname -s) $(uname -m), profile=$profile"
|
||||
HOME="$home" "$bench_bin" \
|
||||
--client "$client_bin" \
|
||||
--server local \
|
||||
--dosh-host 127.0.0.1 \
|
||||
--dosh-port "$dosh_port" \
|
||||
--session default \
|
||||
--skip-ssh-baseline \
|
||||
--iterations "$iters" \
|
||||
--label "$label" \
|
||||
$json_flag \
|
||||
"$@"
|
||||
}
|
||||
|
||||
echo "dosh local benchmark: port=$dosh_port iters=$iters profile=$profile home=$home" >&2
|
||||
|
||||
# 1) Native cold auth + cached attach-ticket in one run (ticket cache on).
|
||||
write_client_config true
|
||||
start_server
|
||||
echo "== native cold auth + cached attach-ticket ==" >&2
|
||||
run_bench --cold-native --cached-ticket
|
||||
# Sanity: native cold auth must have trusted the host (proves the generated
|
||||
# client config loaded and the native handshake ran instead of silently
|
||||
# falling back to a different path).
|
||||
if [ ! -s "$home/.config/dosh/known_hosts" ]; then
|
||||
echo "native cold auth did not record a trusted host; check server.log:" >&2
|
||||
cat "$home/server.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
server_pid=""
|
||||
|
||||
# 2) Self-contained local-auth path (no SSH, no native handshake).
|
||||
#
|
||||
# Note on UDP resume: resume is the roaming path for a client that is STILL
|
||||
# attached when its network endpoint changes. The `--attach-only` benchmark
|
||||
# model detaches after each iteration, which tears the client down on the
|
||||
# server, so a fresh-process resume has nothing live to resume and is not
|
||||
# meaningful here. The cold fresh-process reconnect fast path is the attach
|
||||
# ticket (measured above). Roaming resume is validated by the integration test
|
||||
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
|
||||
# scenarios that keep a live session out-of-band (e.g. remote soak tests); see
|
||||
# docs/BENCHMARKS.md.
|
||||
rm -rf "$home/.local/share/dosh/credentials"
|
||||
write_client_config true
|
||||
start_server
|
||||
echo "== local-auth (no SSH) ==" >&2
|
||||
run_bench --local-auth --no-cache
|
||||
Executable
+21
@@ -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:-}
|
||||
@@ -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" \
|
||||
|
||||
Executable
+22
@@ -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
|
||||
Executable
+85
@@ -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
|
||||
Executable
+98
@@ -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" || 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
|
||||
@@ -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);
|
||||
|
||||
+495
-82
@@ -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
|
||||
};
|
||||
|
||||
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) {
|
||||
if !args.local_auth && !args.skip_ssh_baseline {
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
Ok(())
|
||||
/// 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,16 +253,55 @@ fn time_dosh_attach(
|
||||
if let Some(host) = &args.dosh_host {
|
||||
cmd.arg("--dosh-host").arg(host);
|
||||
}
|
||||
|
||||
match path {
|
||||
DoshPath::LocalAuth => {
|
||||
cmd.arg("--local-auth");
|
||||
// While warming, write the cache; measured runs honor --no-cache.
|
||||
if args.no_cache && !warm {
|
||||
cmd.arg("--no-cache");
|
||||
}
|
||||
cmd.arg(&args.server);
|
||||
}
|
||||
DoshPath::CachedTicket | DoshPath::Resume => {
|
||||
// Fast paths must read the warmed cache, so never pass --no-cache here.
|
||||
// Whether the cache uses tickets vs resume is decided by the client
|
||||
// config (`cache_attach_tickets`) the harness wrote into HOME.
|
||||
if args.local_auth {
|
||||
cmd.arg("--local-auth").arg(&args.server);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
DoshPath::SshBootstrap => {
|
||||
add_bootstrap_args(&mut cmd, args, control_path);
|
||||
// Cold SSH bootstrap honors --no-cache on measured runs.
|
||||
if args.no_cache && !warm {
|
||||
cmd.arg("--no-cache");
|
||||
}
|
||||
cmd.arg(&args.server);
|
||||
}
|
||||
}
|
||||
time_command(&mut cmd)
|
||||
}
|
||||
|
||||
/// Append the SSH bootstrap arguments shared by the non-local paths. Native
|
||||
/// auth reuses the same SSH key/known-hosts/port plumbing.
|
||||
fn add_bootstrap_args(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
|
||||
if args.local_auth {
|
||||
return;
|
||||
}
|
||||
cmd.arg("--ssh-port")
|
||||
.arg(args.ssh_port.to_string())
|
||||
.arg("--ssh-auth-command")
|
||||
.arg(&args.ssh_auth_command);
|
||||
if args.no_cache {
|
||||
cmd.arg("--no-cache");
|
||||
}
|
||||
if let Some(key) = &args.ssh_key {
|
||||
cmd.arg("--ssh-key").arg(key);
|
||||
}
|
||||
@@ -197,9 +311,6 @@ fn time_dosh_attach(
|
||||
if let Some(control_path) = control_path {
|
||||
cmd.arg("--ssh-control-path").arg(control_path);
|
||||
}
|
||||
cmd.arg(&args.server);
|
||||
}
|
||||
time_command(&mut cmd)
|
||||
}
|
||||
|
||||
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
|
||||
@@ -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]"));
|
||||
}
|
||||
}
|
||||
|
||||
+1791
-116
File diff suppressed because it is too large
Load Diff
+1554
-156
File diff suppressed because it is too large
Load Diff
+74
-2
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+431
-39
@@ -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,10 +435,54 @@ 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
|
||||
);
|
||||
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
|
||||
let authorized = authorized_keys
|
||||
.iter()
|
||||
.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 transcript = user_auth_transcript(client, server, auth)?;
|
||||
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"
|
||||
@@ -315,23 +491,69 @@ pub fn verify_native_user_auth(
|
||||
auth.signature.len() == 64,
|
||||
"ssh-ed25519 signature must be 64 bytes"
|
||||
);
|
||||
let authorized = authorized_keys
|
||||
.iter()
|
||||
.find(|key| key.algorithm == "ssh-ed25519" && 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 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)
|
||||
.verify(transcript, &signature)
|
||||
.context("verify native user signature")?;
|
||||
Ok(authorized.clone())
|
||||
}
|
||||
"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(
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+212
-14
@@ -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 {
|
||||
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,12 +270,14 @@ pub fn spawn_pty_session(
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
|
||||
let _ = tx.send(PtyOutput {
|
||||
session: reader_session.clone(),
|
||||
bytes: buf[..n].to_vec(),
|
||||
bytes: chunk.to_vec(),
|
||||
exited: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.send(PtyOutput {
|
||||
session: reader_session.clone(),
|
||||
@@ -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
@@ -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
+1184
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
//! Parser robustness tests (Track B, spec milestone 5 / §16 "Fuzz packet parsing").
|
||||
//!
|
||||
//! These throw arbitrary/garbage bytes at every reachable public parser in the
|
||||
//! `dosh` library and assert that NONE of them panic. A parser is allowed to
|
||||
//! return `Ok` (if the bytes happened to be valid) or `Err`, but a panic on
|
||||
//! untrusted input is a denial-of-service / robustness bug against a hostile
|
||||
//! network attacker (threat model §5: "Active network attacker that can spoof
|
||||
//! ... or modify packets").
|
||||
//!
|
||||
//! Determinism: a fixed-seed PRNG (`rand::rngs::StdRng`) is used so failures are
|
||||
//! reproducible. No external dependencies beyond what is already in Cargo.toml.
|
||||
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
|
||||
use dosh::auth::{
|
||||
AttachTicketPlain, BootstrapResponse, SealedAttachTicket, decode_bootstrap, open_attach_ticket,
|
||||
verify_attach_ticket,
|
||||
};
|
||||
use dosh::native::{
|
||||
AuthorizedKey, HostPublicKey, KnownHost, NativeAuthOk, NativeClientHello, NativeServerHello,
|
||||
NativeUserAuth, parse_authorized_keys, parse_host_public_key_line, parse_known_hosts,
|
||||
parse_ssh_ed25519_public_blob, verify_known_host,
|
||||
};
|
||||
use dosh::protocol::{
|
||||
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Header, Input,
|
||||
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
|
||||
NativeUserAuthBody, Packet, Resize, ResumeRequest, StreamClose, StreamData, StreamEof,
|
||||
StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
|
||||
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||
};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, RngCore, SeedableRng};
|
||||
|
||||
const ITERATIONS: usize = 4000;
|
||||
|
||||
/// Run `f` and convert a panic into a test failure with a descriptive message.
|
||||
fn no_panic<F: FnOnce()>(label: &str, input: &[u8], f: F) {
|
||||
let result = panic::catch_unwind(AssertUnwindSafe(f));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"parser `{label}` PANICKED on input ({} bytes): {:02x?}",
|
||||
input.len(),
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate a variety of "interesting" byte buffers for a given iteration.
|
||||
fn fuzz_bytes(rng: &mut StdRng) -> Vec<u8> {
|
||||
let strategy = rng.gen_range(0..7u8);
|
||||
match strategy {
|
||||
0 => {
|
||||
let len = rng.gen_range(0..1200);
|
||||
let mut buf = vec![0u8; len];
|
||||
rng.fill_bytes(&mut buf);
|
||||
buf
|
||||
}
|
||||
1 => {
|
||||
let len = rng.gen_range(0..16);
|
||||
let mut buf = vec![0u8; len];
|
||||
rng.fill_bytes(&mut buf);
|
||||
buf
|
||||
}
|
||||
2 => {
|
||||
let len = rng.gen_range(0..(protocol::HEADER_LEN + 64));
|
||||
let mut buf = vec![0u8; len];
|
||||
rng.fill_bytes(&mut buf);
|
||||
buf
|
||||
}
|
||||
3 => vec![0u8; rng.gen_range(0..256)],
|
||||
4 => vec![0xffu8; rng.gen_range(0..256)],
|
||||
5 => {
|
||||
// A valid-magic prefix followed by garbage to drive deeper paths.
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(protocol::MAGIC);
|
||||
buf.push(protocol::VERSION);
|
||||
let extra = rng.gen_range(0..256);
|
||||
let mut tail = vec![0u8; extra];
|
||||
rng.fill_bytes(&mut tail);
|
||||
buf.extend_from_slice(&tail);
|
||||
buf
|
||||
}
|
||||
_ => {
|
||||
// Large length prefixes to provoke huge allocations / overflow in
|
||||
// length fields (a classic deserialization hazard).
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
let extra = rng.gen_range(0..64);
|
||||
let mut tail = vec![0u8; extra];
|
||||
rng.fill_bytes(&mut tail);
|
||||
buf.extend_from_slice(&tail);
|
||||
buf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a possibly-valid UTF-8 string from random bytes (for text parsers).
|
||||
fn fuzz_text(rng: &mut StdRng) -> String {
|
||||
let len = rng.gen_range(0..256);
|
||||
let mut s = String::new();
|
||||
for _ in 0..len {
|
||||
let pick = rng.gen_range(0..10u8);
|
||||
let ch = match pick {
|
||||
0 => ' ',
|
||||
1 => '\n',
|
||||
2 => '\t',
|
||||
3 => '=',
|
||||
4 => ',',
|
||||
5 => '"',
|
||||
6 => '/',
|
||||
7 => rng.gen_range(b'a'..=b'z') as char,
|
||||
8 => rng.gen_range(b'0'..=b'9') as char,
|
||||
_ => char::from_u32(rng.gen_range(0..0x110000)).unwrap_or('?'),
|
||||
};
|
||||
s.push(ch);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_packet_decode_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0xD05Au64);
|
||||
for _ in 0..ITERATIONS {
|
||||
let input = fuzz_bytes(&mut rng);
|
||||
no_panic("protocol::decode", &input, || {
|
||||
let _ = protocol::decode(&input);
|
||||
});
|
||||
no_panic("Header::parse", &input, || {
|
||||
let _ = Header::parse(&input);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `from_body` deserializes a bincode body into each protocol/native struct.
|
||||
/// On the wire this runs on attacker-controlled bytes, so it must never panic.
|
||||
#[test]
|
||||
fn protocol_from_body_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0xBEEFu64);
|
||||
|
||||
macro_rules! body_target {
|
||||
($input:expr, $ty:ty) => {{
|
||||
let input = $input;
|
||||
no_panic(concat!("from_body::<", stringify!($ty), ">"), input, || {
|
||||
let _ = protocol::from_body::<$ty>(input);
|
||||
});
|
||||
}};
|
||||
}
|
||||
|
||||
for _ in 0..ITERATIONS {
|
||||
let input = fuzz_bytes(&mut rng);
|
||||
let input = input.as_slice();
|
||||
|
||||
// protocol.rs structs
|
||||
body_target!(input, BootstrapAttachRequest);
|
||||
body_target!(input, TicketAttachEnvelope);
|
||||
body_target!(input, TicketAttachBody);
|
||||
body_target!(input, TicketAttachOkEnvelope);
|
||||
body_target!(input, AttachOk);
|
||||
body_target!(input, AttachReject);
|
||||
body_target!(input, ResumeRequest);
|
||||
body_target!(input, Input);
|
||||
body_target!(input, Resize);
|
||||
body_target!(input, Frame);
|
||||
body_target!(input, StreamOpen);
|
||||
body_target!(input, StreamOpenOk);
|
||||
body_target!(input, StreamOpenReject);
|
||||
body_target!(input, StreamData);
|
||||
body_target!(input, StreamWindowAdjust);
|
||||
body_target!(input, StreamEof);
|
||||
body_target!(input, StreamClose);
|
||||
|
||||
// native handshake wrapper bodies
|
||||
body_target!(input, NativeClientHelloBody);
|
||||
body_target!(input, NativeServerHelloBody);
|
||||
body_target!(input, NativeUserAuthBody);
|
||||
body_target!(input, NativeAuthOkBody);
|
||||
body_target!(input, NativeAuthCheckOkBody);
|
||||
|
||||
// bare native handshake structs
|
||||
body_target!(input, NativeClientHello);
|
||||
body_target!(input, NativeServerHello);
|
||||
body_target!(input, NativeUserAuth);
|
||||
body_target!(input, NativeAuthOk);
|
||||
body_target!(input, HostPublicKey);
|
||||
|
||||
// auth.rs structs (deserialized from untrusted material too)
|
||||
body_target!(input, BootstrapResponse);
|
||||
body_target!(input, SealedAttachTicket);
|
||||
body_target!(input, AttachTicketPlain);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full decode -> decrypt_body pipeline on garbage. decrypt should Err (not
|
||||
/// panic) on bad ciphertext / wrong key id / truncated body.
|
||||
#[test]
|
||||
fn protocol_decode_then_decrypt_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0x1234_5678u64);
|
||||
let key = [7u8; 32];
|
||||
for _ in 0..ITERATIONS {
|
||||
let input = fuzz_bytes(&mut rng);
|
||||
no_panic("decode+decrypt_body", &input, || {
|
||||
if let Ok(packet) = protocol::decode(&input) {
|
||||
let _ = protocol::decrypt_body(&packet, &key, protocol::CLIENT_TO_SERVER);
|
||||
let _ = protocol::decrypt_body(&packet, &key, protocol::SERVER_TO_CLIENT);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutate a single byte of a valid encrypted packet; decode and decrypt must
|
||||
/// not panic, and decryption of the mutated packet must fail (no double-apply).
|
||||
#[test]
|
||||
fn protocol_bit_flips_on_valid_packet_never_panic() {
|
||||
let mut rng = StdRng::seed_from_u64(0x900Du64);
|
||||
let key = [9u8; 32];
|
||||
let conn_id = [3u8; 16];
|
||||
for _ in 0..1000 {
|
||||
let mut plaintext = vec![0u8; rng.gen_range(0..200)];
|
||||
rng.fill_bytes(&mut plaintext);
|
||||
let seq = rng.gen_range(1..u64::MAX);
|
||||
let Ok(mut packet) = protocol::encode_encrypted(
|
||||
protocol::PacketKind::Input,
|
||||
conn_id,
|
||||
seq,
|
||||
0,
|
||||
&key,
|
||||
protocol::CLIENT_TO_SERVER,
|
||||
&plaintext,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if packet.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let idx = rng.gen_range(0..packet.len());
|
||||
packet[idx] ^= 1 << rng.gen_range(0..8);
|
||||
no_panic("flip+decode+decrypt", &packet, || {
|
||||
if let Ok(decoded) = protocol::decode(&packet) {
|
||||
let _ = protocol::decrypt_body(&decoded, &key, protocol::CLIENT_TO_SERVER);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_ed25519_blob_parser_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0x5511u64);
|
||||
for _ in 0..ITERATIONS {
|
||||
let input = fuzz_bytes(&mut rng);
|
||||
no_panic("parse_ssh_ed25519_public_blob", &input, || {
|
||||
let _ = parse_ssh_ed25519_public_blob(&input);
|
||||
});
|
||||
}
|
||||
// Targeted: length prefixes that lie about the body length.
|
||||
for bad_len in [0u32, 1, 31, 32, 33, u32::MAX, u32::MAX - 1] {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&bad_len.to_be_bytes());
|
||||
buf.extend_from_slice(b"ssh-ed25519");
|
||||
buf.extend_from_slice(&32u32.to_be_bytes());
|
||||
buf.extend_from_slice(&[0u8; 16]);
|
||||
no_panic("parse_ssh_ed25519_public_blob:lying-len", &buf, || {
|
||||
let _ = parse_ssh_ed25519_public_blob(&buf);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorized_keys_parser_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0xA011u64);
|
||||
for _ in 0..ITERATIONS {
|
||||
let text = fuzz_text(&mut rng);
|
||||
no_panic("parse_authorized_keys", text.as_bytes(), || {
|
||||
let _ = parse_authorized_keys(&text);
|
||||
});
|
||||
}
|
||||
let crafted = [
|
||||
"ssh-ed25519",
|
||||
"ssh-ed25519 ",
|
||||
"ssh-ed25519 not-base64!!!",
|
||||
"from= ssh-ed25519 AAAA",
|
||||
"from=\"unterminated ssh-ed25519 AAAA",
|
||||
"command=\"x\\\" ssh-ed25519 AAAA",
|
||||
"permitopen=,,, ssh-ed25519 AAAA",
|
||||
"restrict,no-port-forwarding,from=\"127.0.0.1\" ssh-ed25519 AAAA comment",
|
||||
"ssh-rsa AAAA",
|
||||
"\u{0}\u{0}\u{0} ssh-ed25519 AAAA",
|
||||
];
|
||||
for line in crafted {
|
||||
no_panic("parse_authorized_keys:crafted", line.as_bytes(), || {
|
||||
let _ = parse_authorized_keys(line);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_hosts_parser_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0xC051u64);
|
||||
for _ in 0..ITERATIONS {
|
||||
let text = fuzz_text(&mut rng);
|
||||
no_panic("parse_known_hosts", text.as_bytes(), || {
|
||||
let _ = parse_known_hosts(&text);
|
||||
});
|
||||
}
|
||||
let crafted = [
|
||||
"host",
|
||||
"host dosh-ed25519",
|
||||
"host dosh-ed25519 not-base64!!!",
|
||||
"host wrong-algo AAAA",
|
||||
"host dosh-ed25519 AAAA first-seen=notnum source=tofu",
|
||||
"host dosh-ed25519 AAAA first-seen= source=",
|
||||
"* dosh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
];
|
||||
for line in crafted {
|
||||
no_panic("parse_known_hosts:crafted", line.as_bytes(), || {
|
||||
let _ = parse_known_hosts(line);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_public_key_line_parser_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0x4002u64);
|
||||
for _ in 0..ITERATIONS {
|
||||
let text = fuzz_text(&mut rng);
|
||||
no_panic("parse_host_public_key_line", text.as_bytes(), || {
|
||||
let _ = parse_host_public_key_line(&text);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_bootstrap_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0xB007u64);
|
||||
for _ in 0..ITERATIONS {
|
||||
let text = fuzz_text(&mut rng);
|
||||
no_panic("decode_bootstrap", text.as_bytes(), || {
|
||||
let _ = decode_bootstrap(&text);
|
||||
});
|
||||
// Also feed base64-shaped random for the decode path proper.
|
||||
let raw = fuzz_bytes(&mut rng);
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&raw);
|
||||
no_panic("decode_bootstrap:b64", b64.as_bytes(), || {
|
||||
let _ = decode_bootstrap(&b64);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_ticket_open_and_verify_never_panic() {
|
||||
let mut rng = StdRng::seed_from_u64(0x7CE7u64);
|
||||
let secret = [42u8; 32];
|
||||
let psk = [11u8; 32];
|
||||
for _ in 0..ITERATIONS {
|
||||
let input = fuzz_bytes(&mut rng);
|
||||
no_panic("open_attach_ticket", &input, || {
|
||||
let _ = open_attach_ticket(&secret, &input);
|
||||
});
|
||||
no_panic("verify_attach_ticket", &input, || {
|
||||
let _ = verify_attach_ticket(&secret, &input, &psk, "default", "read-write");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Throw garbage at the known-host verifier (file parse + host key compare).
|
||||
#[test]
|
||||
fn verify_known_host_with_garbage_keys_never_panics() {
|
||||
let mut rng = StdRng::seed_from_u64(0x9090u64);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for _ in 0..500 {
|
||||
let text = fuzz_text(&mut rng);
|
||||
let path = dir.path().join("known_hosts");
|
||||
std::fs::write(&path, &text).unwrap();
|
||||
let mut key_bytes = [0u8; 32];
|
||||
rng.fill_bytes(&mut key_bytes);
|
||||
let host = HostPublicKey {
|
||||
algorithm: "dosh-ed25519".to_string(),
|
||||
key: key_bytes,
|
||||
};
|
||||
let host_name = fuzz_text(&mut rng);
|
||||
no_panic("verify_known_host", text.as_bytes(), || {
|
||||
let _ = verify_known_host(&path, &host_name, &host);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression guard: valid inputs still parse, so the fuzz harness isn't
|
||||
/// accidentally exercising a build where every path simply Errs.
|
||||
#[test]
|
||||
fn valid_inputs_still_parse() {
|
||||
let key = [5u8; 32];
|
||||
let blob = dosh::native::ssh_ed25519_public_blob(&key);
|
||||
assert_eq!(parse_ssh_ed25519_public_blob(&blob).unwrap(), key);
|
||||
|
||||
let session_key = [1u8; 32];
|
||||
let packet = protocol::encode_encrypted(
|
||||
protocol::PacketKind::Input,
|
||||
[2u8; 16],
|
||||
1,
|
||||
0,
|
||||
&session_key,
|
||||
protocol::CLIENT_TO_SERVER,
|
||||
b"hello",
|
||||
)
|
||||
.unwrap();
|
||||
let decoded: Packet = protocol::decode(&packet).unwrap();
|
||||
let plain = protocol::decrypt_body(&decoded, &session_key, protocol::CLIENT_TO_SERVER).unwrap();
|
||||
assert_eq!(plain, b"hello");
|
||||
|
||||
assert!(parse_authorized_keys("").unwrap().is_empty());
|
||||
assert!(parse_known_hosts("# just a comment\n").unwrap().is_empty());
|
||||
|
||||
// Reference types only otherwise used in macro expansions / signatures.
|
||||
let _ = std::mem::size_of::<AuthorizedKey>();
|
||||
let _ = std::mem::size_of::<KnownHost>();
|
||||
}
|
||||
@@ -126,6 +126,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_foreign_wire_version_flags_only_version_skew() {
|
||||
let key = crypto::random_32();
|
||||
let mut packet = protocol::encode_encrypted(
|
||||
PacketKind::Input,
|
||||
crypto::random_16(),
|
||||
1,
|
||||
0,
|
||||
&key,
|
||||
CLIENT_TO_SERVER,
|
||||
b"hi",
|
||||
)
|
||||
.unwrap();
|
||||
// A correctly framed packet for this build is not "foreign".
|
||||
assert_eq!(protocol::peek_foreign_wire_version(&packet), None);
|
||||
// Bumping the wire version byte makes it undecodable but recognizable.
|
||||
packet[4] = protocol::VERSION.wrapping_add(7);
|
||||
assert_eq!(
|
||||
protocol::peek_foreign_wire_version(&packet),
|
||||
Some(protocol::VERSION.wrapping_add(7))
|
||||
);
|
||||
assert!(protocol::decode(&packet).is_err());
|
||||
// Non-Dosh datagrams and runts are ignored.
|
||||
assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None);
|
||||
assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_key_derivation_agrees_and_is_independent_per_epoch() {
|
||||
use dosh::native::derive_rekey_session_key;
|
||||
|
||||
// The handshake/current key both peers already share.
|
||||
let current_key = crypto::random_32();
|
||||
let current_id = protocol::session_key_id(¤t_key);
|
||||
// Fresh server-generated material, delivered confidentially in the Rekey.
|
||||
let material = crypto::random_32();
|
||||
|
||||
// Both peers derive identically from shared current key + shipped material.
|
||||
let server_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||
let client_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||
assert_eq!(server_view, client_view);
|
||||
|
||||
// The rotated key must not equal the handshake/current key.
|
||||
assert_ne!(server_view, current_key);
|
||||
|
||||
// A different epoch (or different material) yields a different key.
|
||||
let next_epoch = derive_rekey_session_key(¤t_key, &material, ¤t_id, 2).unwrap();
|
||||
assert_ne!(server_view, next_epoch);
|
||||
let other_material = crypto::random_32();
|
||||
let other = derive_rekey_session_key(¤t_key, &other_material, ¤t_id, 1).unwrap();
|
||||
assert_ne!(server_view, other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_round_trip_decrypts_old_and_new_epoch_packets() {
|
||||
use dosh::native::derive_rekey_session_key;
|
||||
|
||||
let key_epoch0 = crypto::random_32();
|
||||
let id0 = protocol::session_key_id(&key_epoch0);
|
||||
let conn_id = crypto::random_16();
|
||||
|
||||
// Pre-rekey packet sealed under epoch-0 key.
|
||||
let pre = protocol::encode_encrypted(
|
||||
PacketKind::Frame,
|
||||
conn_id,
|
||||
5,
|
||||
0,
|
||||
&key_epoch0,
|
||||
protocol::SERVER_TO_CLIENT,
|
||||
b"before",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Rotate to epoch 1.
|
||||
let material = crypto::random_32();
|
||||
let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap();
|
||||
let post = protocol::encode_encrypted(
|
||||
PacketKind::Frame,
|
||||
conn_id,
|
||||
6,
|
||||
0,
|
||||
&key_epoch1,
|
||||
protocol::SERVER_TO_CLIENT,
|
||||
b"after",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pre = protocol::decode(&pre).unwrap();
|
||||
let post = protocol::decode(&post).unwrap();
|
||||
|
||||
// Each epoch's key carries its own session_key_id; the receiver picks the
|
||||
// right one and both decrypt correctly.
|
||||
assert_eq!(pre.header.session_key_id, id0);
|
||||
assert_eq!(
|
||||
pre.header.session_key_id,
|
||||
protocol::session_key_id(&key_epoch0)
|
||||
);
|
||||
assert_eq!(
|
||||
post.header.session_key_id,
|
||||
protocol::session_key_id(&key_epoch1)
|
||||
);
|
||||
assert_eq!(
|
||||
protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||
b"before"
|
||||
);
|
||||
assert_eq!(
|
||||
protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||
b"after"
|
||||
);
|
||||
|
||||
// A stale-epoch packet under the wrong key is rejected via session_key_id
|
||||
// BEFORE any AEAD work — ignorable, not a fatal decrypt error.
|
||||
let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err();
|
||||
assert!(err.to_string().contains("session key id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_native_protocol_version_names_the_mismatch() {
|
||||
use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version};
|
||||
check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap();
|
||||
let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server")
|
||||
.unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains(protocol::VERSION_MISMATCH_REASON),
|
||||
"expected actionable upgrade message, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("server"),
|
||||
"should name the wrong peer: {message:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
|
||||
let mut replay = ReplayWindow::new(8);
|
||||
|
||||
Reference in New Issue
Block a user