Add launch hardening gates
This commit is contained in:
@@ -3,6 +3,9 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "17 7 * * 1"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -36,17 +39,11 @@ jobs:
|
||||
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
|
||||
run: |
|
||||
set -e
|
||||
for target in \
|
||||
packet_decode \
|
||||
from_body \
|
||||
authorized_keys \
|
||||
known_hosts \
|
||||
handshake_structs \
|
||||
attach_ticket; do
|
||||
echo "== fuzzing $target =="
|
||||
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
|
||||
-max_total_time=20 -rss_limit_mb=4096
|
||||
done
|
||||
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."
|
||||
|
||||
@@ -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:
|
||||
cargo build --release
|
||||
@@ -28,3 +28,14 @@ bench-docker-ssh:
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
|
||||
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
|
||||
degrades to an unauthenticated mode.
|
||||
|
||||
Native v1 is **not yet fully verified**. Protocol VERSION compatibility policy,
|
||||
long-running sleep/roaming soak tests, adversarial forwarding/network tests, deeper
|
||||
fuzzing, and an external security review are still open. See `docs/THREAT_MODEL.md`
|
||||
for the published threat model and accepted residual risks, and the "Native v1
|
||||
verification checklist status" table in `docs/PUBLIC_READINESS.md` for the
|
||||
item-by-item state. Dosh does not yet claim a fully verified SSH replacement; its
|
||||
defensible claim remains fast encrypted native attach/reconnect with SSH-equivalent
|
||||
transport security and SSH fallback.
|
||||
Native v1 is **not yet fully verified**. Long-running sleep/roaming soak evidence,
|
||||
reliable packet-loss recovery for forwarded stream data, deeper fuzzing evidence,
|
||||
and an external security review are still open. See `docs/THREAT_MODEL.md` for the
|
||||
published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
|
||||
for the v1 versioning policy, and the "Native v1 verification checklist status"
|
||||
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
|
||||
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
|
||||
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
|
||||
|
||||
+10
-9
@@ -13,16 +13,17 @@
|
||||
> 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`, and per-stream flow control exist; terminal-priority/load regressions are
|
||||
> covered, and hostile-network/long-soak proof is still being expanded before parity
|
||||
> is claimed.
|
||||
> - Milestone 4 — forwarding: **implemented with one reliability caveat.** Stream
|
||||
> mux, `-L`, `-R`, `-D`, `-N`, `-f`, and per-stream flow control exist;
|
||||
> terminal-priority/load and replay/reorder regressions are covered. Dropped
|
||||
> `StreamData` recovery still needs stream retransmission before lossy-link
|
||||
> forwarding parity is claimed.
|
||||
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
|
||||
> fail-closed protocol-version checks, parser fuzz targets, fuzz-smoke CI, scripted
|
||||
> TUI transport tests, forwarding load/priority tests, and independent persistent
|
||||
> session restart tests exist. External review, long soak, and future multi-version
|
||||
> compatibility policy are not yet complete. The threat model is published
|
||||
> (`docs/THREAT_MODEL.md`).
|
||||
> 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 tests, and independent persistent session restart
|
||||
> tests exist. External review and published long-soak evidence are not yet
|
||||
> complete. The threat model is published (`docs/THREAT_MODEL.md`).
|
||||
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
|
||||
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
|
||||
> ongoing.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
`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, adversarial UDP forwarding tests, deeper
|
||||
fuzzing, protocol-version compatibility policy, and external review are still open.
|
||||
fully verified**: long sleep/roaming soak, packet-loss recovery for forwarding,
|
||||
deeper fuzzing results, and external review are still open.
|
||||
Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is green and that
|
||||
review is done, Dosh's defensible public security claim remains fast encrypted native
|
||||
attach/reconnect with SSH-equivalent transport security and an explicit SSH bootstrap
|
||||
@@ -63,8 +63,8 @@ with ordinary SSH.
|
||||
| `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 | implemented; needs more hostile-network/soak proof |
|
||||
| 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 |
|
||||
@@ -78,9 +78,9 @@ with ordinary SSH.
|
||||
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
|
||||
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
||||
| SSH agent forwarding | no | 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 |
|
||||
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; adversarial UDP pending |
|
||||
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; 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; packet-loss recovery 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 |
|
||||
| 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:
|
||||
|
||||
- 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
|
||||
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. |
|
||||
| Multiple forwards in one command | done | Forward lists parsed and started together. |
|
||||
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
|
||||
| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. |
|
||||
| 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. |
|
||||
| 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`. |
|
||||
|
||||
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: current behavior is single-version, fail-closed reject
|
||||
at the packet/header and native-handshake layers. Future multi-version
|
||||
compatibility/downgrade policy is still open.
|
||||
- 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.
|
||||
@@ -182,12 +183,17 @@ Additional security hardening tracked outside the section 16 list:
|
||||
## Before Public Launch
|
||||
|
||||
- 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
|
||||
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.
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
+18
-14
@@ -188,24 +188,28 @@ be evaluated honestly. Items here are *not* yet "green".
|
||||
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 policy is still simple.** The wire format pins a
|
||||
single protocol version: the packet header rejects any non-matching `VERSION` byte
|
||||
and the native handshake rejects any non-matching `protocol_version`. There is no
|
||||
multi-version negotiation yet, so cross-version interop and downgrade-resistance for
|
||||
future versions are still being designed. Today's behavior is fail-closed (reject),
|
||||
not silent downgrade.
|
||||
- **Fuzzing is smoke-tested in CI, not yet a deep campaign.** CI runs parser/auth
|
||||
fuzz targets briefly with nightly/cargo-fuzz when available. That catches obvious
|
||||
panics and parser robustness regressions; it is not a substitute for longer
|
||||
coverage-guided fuzz campaigns before public security 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.
|
||||
- **Hostile-network and long-soak integration tests are partial.** Roaming,
|
||||
retransmit, resize, and multi-client tests exist; a dedicated adversarial
|
||||
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being
|
||||
expanded.
|
||||
- **Forwarded stream data is not yet reliable across arbitrary UDP loss.** Terminal
|
||||
frames have retransmit coverage; forwarding streams have flow control and
|
||||
replay/reorder tests, but dropped `StreamData` recovery requires stream
|
||||
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
|
||||
review checklist before public security claims. That review has not happened.
|
||||
|
||||
|
||||
+10
-2
@@ -35,6 +35,12 @@ cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
|
||||
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
|
||||
|
||||
@@ -55,5 +61,7 @@ cargo +nightly fuzz run packet_decode -- -max_total_time=10
|
||||
## CI
|
||||
|
||||
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
|
||||
cargo-fuzz and runs each target briefly (`-max_total_time`). The job is tolerant
|
||||
if the toolchain/tooling is unavailable so it never blocks the main test gate.
|
||||
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.
|
||||
|
||||
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
|
||||
+359
-3
@@ -20,7 +20,8 @@
|
||||
//! tests are reproducible and fast.
|
||||
|
||||
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::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -28,13 +29,18 @@ use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::thread;
|
||||
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::config::load_server_config;
|
||||
use dosh::crypto;
|
||||
use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello};
|
||||
use dosh::protocol::{
|
||||
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest,
|
||||
SERVER_TO_CLIENT,
|
||||
self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
|
||||
NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
|
||||
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk,
|
||||
};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
@@ -79,6 +85,23 @@ persist_sessions = false
|
||||
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 {
|
||||
let server = env!("CARGO_BIN_EXE_dosh-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(
|
||||
socket: &UdpSocket,
|
||||
relay: &Relay,
|
||||
@@ -431,6 +570,105 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
|
||||
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)> {
|
||||
let mut buf = [0u8; 65535];
|
||||
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]
|
||||
fn stale_packets_after_resume_are_ignored_not_fatal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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]
|
||||
fn transport_rekey_round_trip_keeps_session_alive() {
|
||||
use dosh::native::derive_rekey_session_key;
|
||||
|
||||
Reference in New Issue
Block a user