Add launch hardening gates

This commit is contained in:
DuProcess
2026-06-18 09:45:27 -04:00
parent 25d9a6aefa
commit d0d6f59cdf
11 changed files with 583 additions and 61 deletions
+8 -11
View File
@@ -3,6 +3,9 @@ name: ci
on: on:
push: push:
pull_request: pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs: jobs:
test: test:
@@ -36,17 +39,11 @@ jobs:
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success' if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: | run: |
set -e set -e
for target in \ if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
packet_decode \ DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
from_body \ else
authorized_keys \ sh scripts/fuzz-run.sh 20
known_hosts \ fi
handshake_structs \
attach_ticket; do
echo "== fuzzing $target =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time=20 -rss_limit_mb=4096
done
- name: Note when fuzzing was skipped - name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success' if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run." run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
+12 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh .PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local
build: build:
cargo build --release cargo build --release
@@ -28,3 +28,14 @@ bench-docker-ssh:
bench-docker-mosh: bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh 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
+22 -8
View File
@@ -218,6 +218,20 @@ fast path after the first authentication has issued an attach ticket. See
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current `docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
strongest claim is fast attach/reconnect, not full Mosh feature parity yet. strongest claim is fast attach/reconnect, not full Mosh feature parity yet.
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 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 `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
configured. configured.
@@ -303,11 +317,11 @@ 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 explicitly when native auth is disabled, unavailable, or rejected. It never silently
degrades to an unauthenticated mode. degrades to an unauthenticated mode.
Native v1 is **not yet fully verified**. Protocol VERSION compatibility policy, Native v1 is **not yet fully verified**. Long-running sleep/roaming soak evidence,
long-running sleep/roaming soak tests, adversarial forwarding/network tests, deeper reliable packet-loss recovery for forwarded stream data, deeper fuzzing evidence,
fuzzing, and an external security review are still open. See `docs/THREAT_MODEL.md` and an external security review are still open. See `docs/THREAT_MODEL.md` for the
for the published threat model and accepted residual risks, and the "Native v1 published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
verification checklist status" table in `docs/PUBLIC_READINESS.md` for the for the v1 versioning policy, and the "Native v1 verification checklist status"
item-by-item state. Dosh does not yet claim a fully verified SSH replacement; its table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
defensible claim remains fast encrypted native attach/reconnect with SSH-equivalent claim a fully verified SSH replacement; its defensible claim remains fast encrypted
transport security and SSH fallback. native attach/reconnect with SSH-equivalent transport security and SSH fallback.
+10 -9
View File
@@ -13,16 +13,17 @@
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates > the default with explicit, visible SSH fallback. Local and Docker benchmark gates
> cover cached attach, SSH fallback, and native cold auth (Track C / > cover cached attach, SSH fallback, and native cold auth (Track C /
> `BENCHMARKS.md`). > `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`, > - Milestone 4 — forwarding: **implemented with one reliability caveat.** Stream
> `-f`, and per-stream flow control exist; terminal-priority/load regressions are > mux, `-L`, `-R`, `-D`, `-N`, `-f`, and per-stream flow control exist;
> covered, and hostile-network/long-soak proof is still being expanded before parity > terminal-priority/load and replay/reorder regressions are covered. Dropped
> is claimed. > `StreamData` recovery still needs stream retransmission before lossy-link
> forwarding parity is claimed.
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting, > - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
> fail-closed protocol-version checks, parser fuzz targets, fuzz-smoke CI, scripted > fail-closed protocol-version checks with a documented v1 policy, parser fuzz
> TUI transport tests, forwarding load/priority tests, and independent persistent > targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests,
> session restart tests exist. External review, long soak, and future multi-version > forwarding load/priority/replay tests, and independent persistent session restart
> compatibility policy are not yet complete. The threat model is published > tests exist. External review and published long-soak evidence are not yet
> (`docs/THREAT_MODEL.md`). > complete. The threat model is published (`docs/THREAT_MODEL.md`).
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust > - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is > management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing. > ongoing.
+40
View File
@@ -0,0 +1,40 @@
# Dosh Protocol Versioning
Dosh v1 uses a deliberately simple compatibility policy: **single-version,
fail-closed, explicit error**.
The wire header carries `protocol::VERSION`. The native handshake carries
`native::NATIVE_PROTOCOL_VERSION`. A peer that speaks any other version is rejected
before application data is accepted:
- foreign wire `VERSION` packets get an `AttachReject` with
`protocol version mismatch - upgrade dosh`;
- foreign native handshake `protocol_version` values get the same named upgrade
error with local/remote versions;
- there is no silent downgrade, compatibility fallback, or best-effort decoding.
## When To Bump
Bump `protocol::VERSION` when a change affects packet framing, packet kind meaning,
serialized protocol structs carried outside native handshake negotiation, or anything
an older peer could misparse.
Bump `native::NATIVE_PROTOCOL_VERSION` when a change affects native handshake
transcripts, native auth semantics, algorithm negotiation, attach tickets, or any
field that is signed or key-derived by native auth.
If both layers are affected, bump both.
## Compatibility Window
Native v1 supports exactly the current version. That keeps the implementation small
and makes security review tractable. Multi-version negotiation can be added later
only with an explicit downgrade-resistance design:
- negotiated version must be transcript-bound;
- the selected version must be visible in diagnostics;
- tests must prove an active attacker cannot force an older mutually supported
version;
- unsupported peers must still get the same named upgrade error.
Until that exists, the public policy is: upgrade both sides together.
+19 -13
View File
@@ -10,8 +10,8 @@ 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, 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 `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 feature matrix and the verification-checklist status table below). It is **not yet
fully verified**: long sleep/roaming soak, adversarial UDP forwarding tests, deeper fully verified**: long sleep/roaming soak, packet-loss recovery for forwarding,
fuzzing, protocol-version compatibility policy, and external review are still open. deeper fuzzing results, and external review are still open.
Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is green and that Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is green and that
review is done, Dosh's defensible public security claim remains fast encrypted native review is done, Dosh's defensible public security claim remains fast encrypted native
attach/reconnect with SSH-equivalent transport security and an explicit SSH bootstrap attach/reconnect with SSH-equivalent transport security and an explicit SSH bootstrap
@@ -63,8 +63,8 @@ with ordinary SSH.
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | 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 | | `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready | | Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | implemented; needs more hostile-network/soak proof | | Roaming by client address change | yes | yes | implemented; hostile-network covered, 30-minute soak gate available |
| Survive sleep or network loss | yes | yes | needs long-running soak tests | | 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 | | Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator | | Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready | | One UDP port for all sessions | port range by default | yes | ready |
@@ -78,9 +78,9 @@ with ordinary SSH.
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity | | Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately | | X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only | | 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 tested, adversarial UDP pending | | Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority and replay/reorder tested; packet-loss recovery pending |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; adversarial UDP pending | | Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; packet-loss recovery pending |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; adversarial UDP pending | | Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; packet-loss recovery pending |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented | | 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 | | Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests |
@@ -130,7 +130,9 @@ Implemented:
Still open before claiming forwarding parity: Still open before claiming forwarding parity:
- A dedicated dropped/reordered/replayed-UDP forwarding test suite. - Reliable packet-loss recovery for forwarded stream data. Reorder and replay are
covered by `forwarded_stream_data_survives_reorder_and_rejects_replay`; true
dropped `StreamData` recovery requires stream retransmission.
- More real-host load soak. The integration suite already covers terminal-priority - More real-host load soak. The integration suite already covers terminal-priority
behavior while a local forward is under blocked-stream pressure. behavior while a local forward is under blocked-stream pressure.
@@ -162,19 +164,18 @@ from code; in progress = partially implemented; pending = not yet implemented.
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. | | `-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. | | 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. | | `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. | | Closing laptop 30+ min does not kill session | in progress | `sleep_roaming_soak_30m` is an ignored launch gate; run `make soak-local` for 1800s evidence. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. | | 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. | | 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 and CI runs a nightly `fuzz-smoke` job when cargo-fuzz is available. | | 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`. | | Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list: Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: implemented for native auth and covered by - 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. unit/integration tests. It still needs real-host tuning under abusive traffic.
- Protocol VERSION handling: current behavior is single-version, fail-closed reject - Protocol VERSION handling: v1 policy is single-version, fail-closed reject at the
at the packet/header and native-handshake layers. Future multi-version packet/header and native-handshake layers. See `docs/PROTOCOL_VERSIONING.md`.
compatibility/downgrade policy is still open.
- ECDSA P-256 / SHA-2 RSA user keys: implemented for ssh-agent and OpenSSH - 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; identity-file native auth. RSA is compatibility-only and uses SHA-2 signatures;
legacy SHA-1 `ssh-rsa` signatures are not accepted. legacy SHA-1 `ssh-rsa` signatures are not accepted.
@@ -182,12 +183,17 @@ Additional security hardening tracked outside the section 16 list:
## Before Public Launch ## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green. - Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Run `make soak-local` before launch to produce 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 - Keep scripted TUI tests green and add a broader app matrix for real alternate-screen
tools. tools.
- Publish benchmark output with raw samples, not just averages. - Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model. - Mark prediction as experimental until it has a real framebuffer model.
- Stress-test `persist_sessions = true` before making restart-survivable holders the - Stress-test `persist_sessions = true` before making restart-survivable holders the
default. default.
- Add reliable retransmission for forwarded stream data before claiming TCP
forwarding survives arbitrary UDP packet loss.
- Tune the native auth token bucket under abusive real-host traffic. - Tune the native auth token bucket under abusive real-host traffic.
- Complete the native-v1 verification checklist above and an external security review - Complete the native-v1 verification checklist above and an external security review
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17). before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17).
+18 -14
View File
@@ -188,24 +188,28 @@ be evaluated honestly. Items here are *not* yet "green".
half-finished handshakes on a TTL, and reports remaining capacity in half-finished handshakes on a TTL, and reports remaining capacity in
`ServerHello`. It is covered by unit/integration tests, but still needs abusive `ServerHello`. It is covered by unit/integration tests, but still needs abusive
real-host tuning before public hardening claims. real-host tuning before public hardening claims.
- **Protocol VERSION compatibility policy is still simple.** The wire format pins a - **Protocol VERSION compatibility is intentionally single-version for v1.** The
single protocol version: the packet header rejects any non-matching `VERSION` byte packet header rejects any non-matching `VERSION` byte and the native handshake
and the native handshake rejects any non-matching `protocol_version`. There is no rejects any non-matching `protocol_version`; peers get a named upgrade error, not
multi-version negotiation yet, so cross-version interop and downgrade-resistance for a silent downgrade. `docs/PROTOCOL_VERSIONING.md` defines the bump rules and the
future versions are still being designed. Today's behavior is fail-closed (reject), post-v1 requirements for any future multi-version negotiation.
not silent downgrade. - **Deep fuzzing still needs launch evidence.** CI runs parser/auth fuzz targets for
- **Fuzzing is smoke-tested in CI, not yet a deep campaign.** CI runs parser/auth 20 seconds per target on push/PR and 300 seconds per target on weekly/manual runs
fuzz targets briefly with nightly/cargo-fuzz when available. That catches obvious when nightly/cargo-fuzz is available. That catches obvious panics and parser
panics and parser robustness regressions; it is not a substitute for longer robustness regressions; public security claims should cite a completed
coverage-guided fuzz campaigns before public security claims. `make fuzz-deep` or scheduled CI run with durations.
- **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256, - **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 and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
identity files. RSA remains compatibility-only and deliberately rejects legacy identity files. RSA remains compatibility-only and deliberately rejects legacy
SHA-1 `ssh-rsa` signatures. SHA-1 `ssh-rsa` signatures.
- **Hostile-network and long-soak integration tests are partial.** Roaming, - **Forwarded stream data is not yet reliable across arbitrary UDP loss.** Terminal
retransmit, resize, and multi-client tests exist; a dedicated adversarial frames have retransmit coverage; forwarding streams have flow control and
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being replay/reorder tests, but dropped `StreamData` recovery requires stream
expanded. retransmission before Dosh should claim TCP-forwarding parity on lossy links.
- **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 external security review yet.** The spec's milestone 5 requires an external - **No external security review yet.** The spec's milestone 5 requires an external
review checklist before public security claims. That review has not happened. review checklist before public security claims. That review has not happened.
+10 -2
View File
@@ -35,6 +35,12 @@ cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
From the repository root: From the repository root:
```sh ```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 # List targets
cargo +nightly fuzz list --fuzz-dir fuzz cargo +nightly fuzz list --fuzz-dir fuzz
@@ -55,5 +61,7 @@ cargo +nightly fuzz run packet_decode -- -max_total_time=10
## CI ## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly + `.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
cargo-fuzz and runs each target briefly (`-max_total_time`). The job is tolerant cargo-fuzz and runs each target. Push/PR runs use a short 20-second-per-target
if the toolchain/tooling is unavailable so it never blocks the main test gate. 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.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}"
targets="
packet_decode
from_body
authorized_keys
known_hosts
handshake_structs
attach_ticket
"
if ! command -v cargo >/dev/null 2>&1; then
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
for target in $targets; do
echo "== fuzzing $target for ${seconds}s =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}"
done
+359 -3
View File
@@ -20,7 +20,8 @@
//! tests are reproducible and fast. //! tests are reproducible and fast.
use std::fs; use std::fs;
use std::net::{SocketAddr, UdpSocket}; use std::io::Read;
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -28,13 +29,18 @@ use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret}; use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config; use dosh::config::load_server_config;
use dosh::crypto; use dosh::crypto;
use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello};
use dosh::protocol::{ use dosh::protocol::{
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest, self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
SERVER_TO_CLIENT, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk,
}; };
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
@@ -79,6 +85,23 @@ persist_sessions = false
config config
} }
fn authorize_ed25519_key(dir: &tempfile::TempDir, signing_key: &SigningKey) {
let verifying = VerifyingKey::from(signing_key);
let mut blob = Vec::new();
write_ssh_string(&mut blob, b"ssh-ed25519");
write_ssh_string(&mut blob, verifying.as_bytes());
fs::write(
dir.path().join("authorized_keys"),
format!("ssh-ed25519 {} hostile-test\n", STANDARD.encode(blob)),
)
.unwrap();
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server"); let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server) let child = Command::new(server)
@@ -402,6 +425,122 @@ fn attach_through_relay(
} }
} }
struct NativeAttached {
socket: UdpSocket,
ok: NativeAuthOk,
}
fn native_attach_through_relay(
relay: &Relay,
signing_key: &SigningKey,
requested_forwardings: Vec<ForwardingRequest>,
) -> NativeAttached {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let (client_secret, client_public) = native::generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: "127.0.0.1".to_string(),
requested_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: native::supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})
.unwrap(),
)
.unwrap();
let hello_deadline = Instant::now() + Duration::from_secs(5);
let server_hello = loop {
socket.send_to(&packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeServerHello => {
let body: NativeServerHelloBody =
protocol::from_body(&packet.body).unwrap();
native::verify_server_hello(&hello, &body.hello).unwrap();
break body.hello;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native hello rejected: {}", reject.reason);
}
kind => panic!("unexpected native hello response: {kind:?}"),
}
}
Err(_) if Instant::now() < hello_deadline => {}
Err(err) => panic!("native server hello timed out: {err}"),
}
};
let session_key = native::derive_native_session_key(
&client_secret,
server_hello.server_ephemeral_public,
&hello,
&server_hello,
)
.unwrap();
let auth =
native::sign_user_auth(signing_key, &hello, &server_hello, requested_forwardings).unwrap();
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth }).unwrap(),
)
.unwrap();
let auth_deadline = Instant::now() + Duration::from_secs(5);
let ok = loop {
socket.send_to(&auth_packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeAuthOk => {
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)
.unwrap();
let body: NativeAuthOkBody = protocol::from_body(&plain).unwrap();
break body.ok;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native auth rejected: {}", reject.reason);
}
kind => panic!("unexpected native auth response: {kind:?}"),
}
}
Err(_) if Instant::now() < auth_deadline => {}
Err(err) => panic!("native auth ok timed out: {err}"),
}
};
NativeAttached { socket, ok }
}
fn send_input( fn send_input(
socket: &UdpSocket, socket: &UdpSocket,
relay: &Relay, relay: &Relay,
@@ -431,6 +570,105 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
socket.send_to(packet, relay.front_addr()).unwrap(); socket.send_to(packet, relay.front_addr()).unwrap();
} }
fn send_stream_packet(
socket: &UdpSocket,
relay: &Relay,
ok: &NativeAuthOk,
kind: PacketKind,
seq: u64,
ack: u64,
body: Vec<u8>,
) {
let packet = protocol::encode_encrypted(
kind,
ok.client_id,
seq,
ack,
&ok.session_key,
CLIENT_TO_SERVER,
&body,
)
.unwrap();
socket.send_to(&packet, relay.front_addr()).unwrap();
}
fn recv_stream_packet<T: serde::de::DeserializeOwned>(
socket: &UdpSocket,
key: &[u8; 32],
kind: PacketKind,
) -> Option<(Header, T)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
let packet = protocol::decode(&buf[..n]).ok()?;
if packet.header.kind != kind {
return None;
}
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
let body = protocol::from_body(&plain).ok()?;
Some((packet.header, body))
}
fn wait_for_stream_open_ok(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<Header> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, ok)) =
recv_stream_packet::<StreamOpenOk>(socket, key, PacketKind::StreamOpenOk)
{
if ok.stream_id == stream_id {
return Some(header);
}
}
}
None
}
fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = channel();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = tx.send(buf[..n].to_vec());
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
(port, rx)
}
fn collect_tcp(rx: &Receiver<Vec<u8>>, millis: u64) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_millis(millis);
let mut out = Vec::new();
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(chunk) => out.extend_from_slice(&chunk),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
out
}
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> { fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
let mut buf = [0u8; 65535]; let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?; let (n, _) = socket.recv_from(&mut buf).ok()?;
@@ -603,6 +841,124 @@ fn duplicated_and_replayed_input_is_applied_at_most_once() {
); );
} }
#[test]
fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let (target_port, tcp_rx) = start_tcp_collector();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xF0E0D0u64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 44;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Swap the first two StreamData packets in flight. The server's replay
// window must accept both out-of-order packets and write both to the TCP
// target exactly once.
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
bytes: b"A".to_vec(),
})
.unwrap(),
);
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
5,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
bytes: b"B".to_vec(),
})
.unwrap(),
);
let mut seen = collect_tcp(&tcp_rx, 1500);
assert!(
seen.contains(&b'A') && seen.contains(&b'B'),
"reordered stream bytes did not both reach TCP target: {:?}",
String::from_utf8_lossy(&seen)
);
// Now send one encrypted StreamData packet repeatedly, with relay-level
// duplication enabled too. This is a true replay: same sequence, same nonce,
// same ciphertext. It must be applied to the TCP target at most once.
let replayed = protocol::encode_encrypted(
PacketKind::StreamData,
attached.ok.client_id,
6,
open_ok.seq,
&attached.ok.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&StreamData {
stream_id,
bytes: b"X".to_vec(),
})
.unwrap(),
)
.unwrap();
relay.set_dup(100);
for _ in 0..12 {
send_raw(&attached.socket, &relay, &replayed);
thread::sleep(Duration::from_millis(25));
}
relay.set_dup(0);
seen.extend(collect_tcp(&tcp_rx, 1500));
let replay_count = seen.iter().filter(|byte| **byte == b'X').count();
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
replay_count <= 1,
"replayed/duplicated StreamData was applied {replay_count} times: {:?}",
String::from_utf8_lossy(&seen)
);
}
#[test] #[test]
fn stale_packets_after_resume_are_ignored_not_fatal() { fn stale_packets_after_resume_are_ignored_not_fatal() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+63
View File
@@ -1495,6 +1495,69 @@ fn resume_updates_udp_endpoint_for_roaming() {
); );
} }
#[test]
#[ignore = "30-minute launch soak; run with `DOSH_SOAK_SECONDS=1800 cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture`"]
fn sleep_roaming_soak_30m() {
let soak_secs = std::env::var("DOSH_SOAK_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(30 * 60);
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_timeout(&dir, port, soak_secs + 60);
let mut server = start_server(&dir, &config);
let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
thread::sleep(Duration::from_secs(soak_secs));
let resumed = UdpSocket::bind("127.0.0.1:0").unwrap();
resumed
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let resume = ResumeRequest {
session: "default".to_string(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&resumed,
port,
PacketKind::ResumeRequest,
ok.client_id,
1,
0,
&bootstrap.session_key,
&protocol::to_body(&resume).unwrap(),
);
let (_header, resume_frame) =
recv_frame(&resumed, &bootstrap.session_key).expect("resume response after soak");
assert!(resume_frame.snapshot);
send_encrypted(
&resumed,
port,
PacketKind::Input,
ok.client_id,
2,
resume_frame.output_seq,
&bootstrap.session_key,
&protocol::to_body(&Input {
bytes: b"printf DOSH_SOAK_OK\\n\n".to_vec(),
})
.unwrap(),
);
let text = collect_frame_text(&resumed, &bootstrap.session_key, 3000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("DOSH_SOAK_OK"),
"session did not survive {soak_secs}s idle/roaming soak; output={text:?}"
);
}
#[test] #[test]
fn transport_rekey_round_trip_keeps_session_alive() { fn transport_rekey_round_trip_keeps_session_alive() {
use dosh::native::derive_rekey_session_key; use dosh::native::derive_rekey_session_key;