Expand native auth and benchmark gates
This commit is contained in:
Generated
+4
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid",
|
||||||
|
"pem-rfc7468",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -452,8 +453,10 @@ dependencies = [
|
|||||||
"portable-pty",
|
"portable-pty",
|
||||||
"rand",
|
"rand",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"signature",
|
||||||
"ssh-key",
|
"ssh-key",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1499,6 +1502,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bcrypt-pbkdf",
|
"bcrypt-pbkdf",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
"num-bigint-dig",
|
||||||
"p256",
|
"p256",
|
||||||
"p384",
|
"p384",
|
||||||
"p521",
|
"p521",
|
||||||
|
|||||||
+3
-1
@@ -19,10 +19,12 @@ hmac = "0.12"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
portable-pty = "0.8"
|
portable-pty = "0.8"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
rsa = { version = "0.9.10", features = ["sha2"] }
|
||||||
rpassword = "7.5.4"
|
rpassword = "7.5.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
sha2 = "0.10"
|
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"] }
|
tokio = { version = "1.41", features = ["full"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ dosh-server
|
|||||||
client table per session
|
client table per session
|
||||||
encrypted UDP protocol
|
encrypted UDP protocol
|
||||||
tiny SSH-invoked dosh-auth helper mode
|
tiny SSH-invoked dosh-auth helper mode
|
||||||
persistent sessions (persist_sessions, default on): each shell runs in a
|
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
|
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
|
SCM_RIGHTS and re-adopts it after a restart, so sessions survive crash/upgrade
|
||||||
|
|
||||||
@@ -197,9 +197,9 @@ Benchmark the ControlMaster-backed SSH bootstrap path:
|
|||||||
target/release/dosh-bench --server user@host --controlmaster --iterations 3
|
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
|
Run the Docker OpenSSH benchmark gate used by CI. It checks cold SSH bootstrap,
|
||||||
and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident
|
ControlMaster-backed SSH bootstrap, native cold auth after one-time `dosh trust`,
|
||||||
`dosh-server`:
|
and cached attach against a containerized `sshd` plus resident `dosh-server`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make bench-docker-ssh
|
make bench-docker-ssh
|
||||||
@@ -211,12 +211,12 @@ Run the same Docker comparison with Mosh installed in the benchmark container:
|
|||||||
make bench-docker-mosh
|
make bench-docker-mosh
|
||||||
```
|
```
|
||||||
|
|
||||||
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same
|
That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`,
|
||||||
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which
|
`dosh_cached_attach_ms`, and, for the Mosh target, `mosh_start_true_ms` under the
|
||||||
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an
|
same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
|
||||||
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly;
|
fast path after the first authentication has issued an attach ticket. See
|
||||||
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature
|
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
|
||||||
parity yet.
|
strongest claim is fast attach/reconnect, not full Mosh feature parity yet.
|
||||||
|
|
||||||
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
|
||||||
@@ -281,10 +281,10 @@ Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substanti
|
|||||||
implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
|
implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
|
||||||
servers:
|
servers:
|
||||||
|
|
||||||
- **Native UDP auth** with X25519 key exchange, transcript-bound Ed25519 user auth
|
- **Native UDP auth** with X25519 key exchange; transcript-bound Ed25519, ECDSA
|
||||||
via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and
|
P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files;
|
||||||
`authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`;
|
ChaCha20-Poly1305 transport; and `authorized_keys` policy enforcement (`from=`,
|
||||||
unsupported options fail closed).
|
`no-port-forwarding`, `permitopen=`; unsupported options fail closed).
|
||||||
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
|
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
|
||||||
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
|
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
|
||||||
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
|
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
|
||||||
@@ -303,10 +303,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**. Per-IP token-bucket rate limiting, protocol
|
Native v1 is **not yet fully verified**. Protocol VERSION compatibility policy,
|
||||||
VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external
|
long-running sleep/roaming soak tests, adversarial forwarding/network tests, deeper
|
||||||
security review are still open. See `docs/THREAT_MODEL.md` for the published threat
|
fuzzing, and an external security review are still open. See `docs/THREAT_MODEL.md`
|
||||||
model and accepted residual risks, and the "Native v1 verification checklist status"
|
for the published threat model and accepted residual risks, and the "Native v1
|
||||||
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
|
verification checklist status" table in `docs/PUBLIC_READINESS.md` for the
|
||||||
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
|
item-by-item state. Dosh does not yet claim a fully verified SSH replacement; its
|
||||||
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
|
defensible claim remains fast encrypted native attach/reconnect with SSH-equivalent
|
||||||
|
transport security and SSH fallback.
|
||||||
|
|||||||
+47
-19
@@ -93,11 +93,13 @@ Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1
|
|||||||
### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`)
|
### `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
|
Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the
|
||||||
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster `dosh_attach_ms`,
|
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster SSH-bootstrap
|
||||||
`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same
|
`dosh_attach_ms`, one-time `dosh trust` followed by native-cold
|
||||||
container, key, and loopback network path. This is the gate CI enforces; it asserts
|
`dosh_cold_native_ms`, `dosh_cached_attach_ms`, and optionally
|
||||||
cold Dosh stays within 500 ms of SSH and that cached attach stays under a small
|
`mosh_start_true_ms` against the same container, key, and loopback network path.
|
||||||
budget. See `README.md` "Develop" for the exact invocations.
|
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
|
## Methodology and caveats
|
||||||
|
|
||||||
@@ -130,8 +132,8 @@ budget. See `README.md` "Develop" for the exact invocations.
|
|||||||
|
|
||||||
## Sample results (loopback, self-contained)
|
## Sample results (loopback, self-contained)
|
||||||
|
|
||||||
Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in
|
Captured with `make bench-local` (`scripts/bench-local.sh 20`), all times in
|
||||||
milliseconds, 30 samples per metric.
|
milliseconds, 20 samples per metric.
|
||||||
|
|
||||||
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
|
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
|
||||||
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
|
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
|
||||||
@@ -141,27 +143,24 @@ milliseconds, 30 samples per metric.
|
|||||||
|
|
||||||
| Metric | n | min | median | p95 | mean | max |
|
| Metric | n | min | median | p95 | mean | max |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `dosh_cold_native_ms` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 |
|
| `dosh_cold_native_ms` | 20 | 8.32 | 8.77 | 9.34 | 8.82 | 9.52 |
|
||||||
| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 |
|
| `dosh_cached_attach_ms` | 20 | 2.83 | 3.23 | 3.75 | 3.24 | 3.77 |
|
||||||
| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 |
|
| `dosh_local_attach_ms` | 20 | 2.84 | 3.21 | 3.96 | 3.28 | 3.98 |
|
||||||
|
|
||||||
Raw samples (ms):
|
Raw samples (ms):
|
||||||
|
|
||||||
```
|
```
|
||||||
dosh_cold_native_ms:
|
dosh_cold_native_ms:
|
||||||
8.32, 8.23, 10.31, 10.37, 8.83, 8.81, 9.20, 9.04, 10.14, 8.98, 10.82, 9.87,
|
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,
|
||||||
10.22, 10.42, 9.76, 9.09, 9.19, 8.94, 8.69, 8.67, 8.74, 9.33, 9.25, 8.92, 8.39,
|
9.52, 8.72, 8.32, 9.06, 8.93, 8.96, 9.04
|
||||||
8.55, 8.38, 9.81, 8.17, 8.10
|
|
||||||
|
|
||||||
dosh_cached_attach_ms:
|
dosh_cached_attach_ms:
|
||||||
2.99, 2.99, 2.93, 2.87, 2.95, 2.80, 2.80, 2.90, 3.10, 2.73, 3.19, 2.80, 2.87,
|
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,
|
||||||
2.82, 2.89, 3.03, 3.06, 3.21, 2.97, 2.83, 3.00, 2.82, 3.04, 2.97, 3.03, 3.24,
|
3.30, 3.77, 3.32, 3.49, 3.75, 3.56, 3.33
|
||||||
3.30, 2.73, 2.79, 3.25
|
|
||||||
|
|
||||||
dosh_local_attach_ms:
|
dosh_local_attach_ms:
|
||||||
3.05, 3.00, 2.84, 3.04, 3.33, 2.89, 2.76, 2.74, 2.73, 3.32, 3.14, 2.75, 2.83,
|
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,
|
||||||
2.88, 2.99, 2.77, 2.75, 2.79, 2.69, 2.78, 2.75, 2.77, 2.75, 2.77, 3.03, 2.75,
|
3.47, 3.13, 2.92, 2.87, 2.91, 2.98, 3.18
|
||||||
2.94, 2.74, 2.82, 2.66
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Reading these numbers: cold native auth is ~9 ms because it pays the native
|
Reading these numbers: cold native auth is ~9 ms because it pays the native
|
||||||
@@ -171,3 +170,32 @@ overhead floor. Over a real link, expect cached attach ≈ this floor + one netw
|
|||||||
round trip — which is exactly the "near network RTT" target in the spec. There is
|
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;
|
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.
|
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
|
||||||
|
```
|
||||||
|
|||||||
+17
-10
@@ -7,16 +7,22 @@
|
|||||||
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
|
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
|
||||||
> `known_hosts`, and mismatch hard-fail are implemented.
|
> `known_hosts`, and mismatch hard-fail are implemented.
|
||||||
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
|
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
|
||||||
> `AuthOk`, ssh-agent and encrypted-key Ed25519, and `authorized_keys` verification
|
> `AuthOk`, ssh-agent and OpenSSH identity-file auth for Ed25519, ECDSA P-256,
|
||||||
> exist. ECDSA/RSA user keys are still pending (Ed25519 only today).
|
> and RSA-SHA2, plus `authorized_keys` verification, exist.
|
||||||
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
|
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
|
||||||
> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are
|
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates
|
||||||
> pending (Track C / `BENCHMARKS.md`).
|
> cover cached attach, SSH fallback, and native cold auth (Track C /
|
||||||
> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and
|
> `BENCHMARKS.md`).
|
||||||
> per-stream flow control are implemented; hostile-network and load tests pending.
|
> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`,
|
||||||
> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting,
|
> `-f`, and per-stream flow control exist; terminal-priority/load regressions are
|
||||||
> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not
|
> covered, and hostile-network/long-soak proof is still being expanded before parity
|
||||||
> yet complete. The threat model is published (`docs/THREAT_MODEL.md`).
|
> 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`).
|
||||||
> - 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.
|
||||||
@@ -124,7 +130,8 @@ Native v1 must be boring under real use:
|
|||||||
- A closed laptop must not kill the remote session.
|
- A closed laptop must not kill the remote session.
|
||||||
- A client crash must not kill the remote session.
|
- A client crash must not kill the remote session.
|
||||||
- A server restart must not kill the remote session when `persist_sessions` is on
|
- A server restart must not kill the remote session when `persist_sessions` is on
|
||||||
(the default): each session's shell runs in a detached per-session *holder*
|
(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
|
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
|
the shell + scrollback survive a server crash/upgrade/`systemctl restart` and a
|
||||||
reattaching client lands on the same shell with its screen restored. With
|
reattaching client lands on the same shell with its screen restored. With
|
||||||
|
|||||||
+41
-34
@@ -8,14 +8,14 @@ The plan for replacing the day-to-day SSH workflow with native Dosh authenticati
|
|||||||
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
|
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:
|
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,
|
||||||
and `dosh doctor` all exist (see the feature matrix and the verification-checklist
|
`dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
|
||||||
status table below). It is **not yet fully verified**: per-IP token-bucket rate
|
feature matrix and the verification-checklist status table below). It is **not yet
|
||||||
limiting, protocol-version negotiation hardening, fuzzing in CI, and external review
|
fully verified**: long sleep/roaming soak, adversarial UDP forwarding tests, deeper
|
||||||
are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is
|
fuzzing, protocol-version compatibility policy, and external review are still open.
|
||||||
green and that review is done, Dosh's defensible public security claim remains fast
|
Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is green and that
|
||||||
encrypted native attach/reconnect with SSH-equivalent transport security and an
|
review is done, Dosh's defensible public security claim remains fast encrypted native
|
||||||
explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH
|
attach/reconnect with SSH-equivalent transport security and an explicit SSH bootstrap
|
||||||
replacement.
|
fallback — not a fully verified, externally reviewed SSH replacement.
|
||||||
|
|
||||||
## Objective Benchmarks
|
## Objective Benchmarks
|
||||||
|
|
||||||
@@ -58,31 +58,31 @@ with ordinary SSH.
|
|||||||
| Feature | Mosh | Dosh now | Public status |
|
| Feature | Mosh | Dosh now | Public status |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| SSH-based first authentication | yes | yes | ready |
|
| SSH-based first authentication | yes | yes | ready |
|
||||||
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519 via ssh-agent or encrypted key | implemented; pending full verification |
|
| 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 |
|
| 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 |
|
| `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 | needs more hostile-network tests |
|
| 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 |
|
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
|
||||||
| 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 |
|
||||||
| Fresh session by default | yes | yes | ready |
|
| Fresh session by default | yes | yes | ready |
|
||||||
| Named persistent sessions | no built-in shared session model | yes | ready |
|
| 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 | needs conflict-policy docs |
|
| Multiple clients on one session | no | yes | implemented; needs conflict-policy docs |
|
||||||
| View-only clients | no | yes | ready |
|
| 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 |
|
| 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 |
|
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
|
||||||
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
||||||
| SSH agent forwarding | no | no | planned as forwarding channel |
|
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only |
|
||||||
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests |
|
| 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; needs hostile-network tests |
|
| 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; needs hostile-network tests |
|
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; adversarial UDP 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; needs load tests |
|
| 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
|
## SSH Config Inheritance
|
||||||
|
|
||||||
@@ -131,7 +131,8 @@ Implemented:
|
|||||||
Still open before claiming forwarding parity:
|
Still open before claiming forwarding parity:
|
||||||
|
|
||||||
- A dedicated dropped/reordered/replayed-UDP forwarding test suite.
|
- A dedicated dropped/reordered/replayed-UDP forwarding test suite.
|
||||||
- Load tests proving large forwarded transfers add no visible terminal input lag.
|
- More real-host load soak. The integration suite already covers terminal-priority
|
||||||
|
behavior while a local forward is under blocked-stream pressure.
|
||||||
|
|
||||||
## Native v1 Verification Checklist Status
|
## Native v1 Verification Checklist Status
|
||||||
|
|
||||||
@@ -145,15 +146,17 @@ from code; in progress = partially implemented; pending = not yet implemented.
|
|||||||
| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. |
|
| 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 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 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`. |
|
| 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. |
|
| 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 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. |
|
| 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. |
|
| 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. |
|
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
|
||||||
| Native cold auth beats cold `ssh host true` | pending | Benchmark gate not yet run for native cold path (Track C / `BENCHMARKS.md`). |
|
| 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 | pending | Same benchmark dependency. |
|
| 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 | in progress | Implemented with per-stream window; load proof pending. |
|
| `-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. |
|
| `-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. |
|
| `-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. |
|
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
|
||||||
@@ -161,26 +164,30 @@ from code; in progress = partially implemented; pending = not yet implemented.
|
|||||||
| `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 | Long `client_timeout_secs` + resume; 30-min soak evidence pending. |
|
||||||
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
|
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
|
||||||
| Large forwarded transfers add no visible input lag | in progress | Per-stream flow control exists; load test pending. |
|
| 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 | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. |
|
| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets and CI runs a nightly `fuzz-smoke` job 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: in progress (another track). Today only
|
- Full per-IP token-bucket rate limiting: implemented for native auth and covered by
|
||||||
handshake-map eviction and a static `rate_limit_remaining` hint exist.
|
unit/integration tests. It still needs real-host tuning under abusive traffic.
|
||||||
- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no
|
- Protocol VERSION handling: current behavior is single-version, fail-closed reject
|
||||||
multi-version negotiation yet.
|
at the packet/header and native-handshake layers. Future multi-version
|
||||||
- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today.
|
compatibility/downgrade policy is still open.
|
||||||
|
- 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
|
## 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.
|
||||||
- Add a non-destructive disconnect indicator.
|
- Keep scripted TUI tests green and add a broader app matrix for real alternate-screen
|
||||||
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
|
tools.
|
||||||
bracketed paste, and terminal cleanup.
|
|
||||||
- Publish benchmark output with raw samples, not just averages.
|
- Publish benchmark output with raw samples, not just averages.
|
||||||
- Mark prediction as experimental until it has a real framebuffer model.
|
- Mark prediction as experimental until it has a real framebuffer model.
|
||||||
- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI.
|
- 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 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).
|
||||||
|
|||||||
+27
-24
@@ -72,13 +72,13 @@ compromised endpoint or a malicious authorized peer.
|
|||||||
| Property | SSH | Dosh native v1 | Notes |
|
| 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. |
|
| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. |
|
||||||
| User authentication by private-key possession | yes | yes | Ed25519 via ssh-agent or encrypted OpenSSH key; signature binds the full transcript. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
|
||||||
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/Ed25519 crates only. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
@@ -94,8 +94,9 @@ compromised endpoint or a malicious authorized peer.
|
|||||||
- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable
|
- **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
|
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.
|
mismatch path, rather than the looser default TOFU behavior most SSH clients ship.
|
||||||
- **Modern primitives only.** Ed25519 and X25519 by default; no DSA, no SHA-1
|
- **Modern primitives only.** Ed25519 and X25519 by default; ECDSA P-256 and
|
||||||
signatures, no CBC-and-MAC constructions.
|
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
|
Dosh does **not** claim generic SSH compatibility and must not be described as an
|
||||||
SSH-protocol implementation.
|
SSH-protocol implementation.
|
||||||
@@ -114,9 +115,12 @@ These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
|
|||||||
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
|
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
|
||||||
not selectable today.
|
not selectable today.
|
||||||
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
|
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
|
||||||
- **User-auth signatures:** Ed25519, produced either by ssh-agent over a Unix socket
|
- **User-auth signatures:** Ed25519, ECDSA P-256, and RSA-SHA2, produced either by
|
||||||
(`src/ssh_agent.rs`) or from an encrypted/plaintext OpenSSH private key
|
ssh-agent over a Unix socket (`src/ssh_agent.rs`) or from an encrypted/plaintext
|
||||||
(`ssh-key`). The signature covers the user-auth transcript described above.
|
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
|
- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
|
||||||
derived session keys, with attach tickets sealed under an HKDF-derived
|
derived session keys, with attach tickets sealed under an HKDF-derived
|
||||||
ticket key. Token comparison is constant-time.
|
ticket key. Token comparison is constant-time.
|
||||||
@@ -179,26 +183,25 @@ be evaluated honestly. Items here are *not* yet "green".
|
|||||||
|
|
||||||
### Known gaps / work in progress (must close before the public claim)
|
### Known gaps / work in progress (must close before the public claim)
|
||||||
|
|
||||||
- **Per-IP rate limiting is partial.** The server evicts half-finished native
|
- **Native-auth rate limiting needs tuning, not first implementation.** The server
|
||||||
handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map
|
enforces a per-source token bucket before expensive native-auth work, evicts
|
||||||
without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`.
|
half-finished handshakes on a TTL, and reports remaining capacity in
|
||||||
A full per-source token-bucket limiter is **in progress on another track** and is
|
`ServerHello`. It is covered by unit/integration tests, but still needs abusive
|
||||||
not yet enforced. Until it lands, sustained auth flooding is mitigated only by the
|
real-host tuning before public hardening claims.
|
||||||
handshake-eviction TTL and OS-level limits.
|
- **Protocol VERSION compatibility policy is still simple.** The wire format pins a
|
||||||
- **Protocol VERSION negotiation is being hardened.** The wire format pins a single
|
single protocol version: the packet header rejects any non-matching `VERSION` byte
|
||||||
protocol version: the packet header rejects any non-matching `VERSION` byte and the
|
and the native handshake rejects any non-matching `protocol_version`. There is no
|
||||||
native handshake rejects any non-matching `protocol_version`. There is no
|
|
||||||
multi-version negotiation yet, so cross-version interop and downgrade-resistance for
|
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),
|
future versions are still being designed. Today's behavior is fail-closed (reject),
|
||||||
not silent downgrade.
|
not silent downgrade.
|
||||||
- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release
|
- **Fuzzing is smoke-tested in CI, not yet a deep campaign.** CI runs parser/auth
|
||||||
build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing,
|
fuzz targets briefly with nightly/cargo-fuzz when available. That catches obvious
|
||||||
authorized-key parsing, known-host parsing, and handshake state (spec milestone 5)
|
panics and parser robustness regressions; it is not a substitute for longer
|
||||||
are **being wired in** and are not yet running in CI.
|
coverage-guided fuzz campaigns before public security claims.
|
||||||
- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA
|
- **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256,
|
||||||
P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and
|
and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
|
||||||
produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not
|
identity files. RSA remains compatibility-only and deliberately rejects legacy
|
||||||
a weakening of what *is* supported.
|
SHA-1 `ssh-rsa` signatures.
|
||||||
- **Hostile-network and long-soak integration tests are partial.** Roaming,
|
- **Hostile-network and long-soak integration tests are partial.** Roaming,
|
||||||
retransmit, resize, and multi-client tests exist; a dedicated adversarial
|
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
|
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
|
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 \
|
if ! HOME="$workdir/home" target/release/dosh-bench \
|
||||||
--server bench@127.0.0.1 \
|
--server bench@127.0.0.1 \
|
||||||
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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 \
|
if ! HOME="$workdir/home-cached" target/release/dosh-bench \
|
||||||
--server bench@127.0.0.1 \
|
--server bench@127.0.0.1 \
|
||||||
--ssh-port "$ssh_port" \
|
--ssh-port "$ssh_port" \
|
||||||
|
|||||||
+21
-18
@@ -10,9 +10,10 @@ use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_ser
|
|||||||
use dosh::crypto;
|
use dosh::crypto;
|
||||||
use dosh::native::{
|
use dosh::native::{
|
||||||
EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, TrustResult,
|
EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, TrustResult,
|
||||||
derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_ed25519_identity,
|
derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_native_identity,
|
||||||
load_ed25519_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host,
|
load_native_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host,
|
||||||
sign_user_auth, trust_host, verify_known_host, verify_server_hello,
|
sign_user_auth_with_private_key, supported_user_key_algorithms, trust_host, verify_known_host,
|
||||||
|
verify_server_hello,
|
||||||
};
|
};
|
||||||
use dosh::protocol::{
|
use dosh::protocol::{
|
||||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||||
@@ -1486,7 +1487,7 @@ async fn try_native_auth(
|
|||||||
requested_mode: mode.to_string(),
|
requested_mode: mode.to_string(),
|
||||||
terminal_size: (cols, rows),
|
terminal_size: (cols, rows),
|
||||||
supported_aead: vec!["chacha20poly1305".to_string()],
|
supported_aead: vec!["chacha20poly1305".to_string()],
|
||||||
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
|
supported_user_key_algorithms: supported_user_key_algorithms(),
|
||||||
cached_host_key_fingerprint: None,
|
cached_host_key_fingerprint: None,
|
||||||
attach_ticket_envelope: None,
|
attach_ticket_envelope: None,
|
||||||
requested_env,
|
requested_env,
|
||||||
@@ -1642,7 +1643,7 @@ async fn try_native_auth_check(
|
|||||||
requested_mode: "doctor".to_string(),
|
requested_mode: "doctor".to_string(),
|
||||||
terminal_size: (80, 24),
|
terminal_size: (80, 24),
|
||||||
supported_aead: vec!["chacha20poly1305".to_string()],
|
supported_aead: vec!["chacha20poly1305".to_string()],
|
||||||
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
|
supported_user_key_algorithms: supported_user_key_algorithms(),
|
||||||
cached_host_key_fingerprint: None,
|
cached_host_key_fingerprint: None,
|
||||||
attach_ticket_envelope: None,
|
attach_ticket_envelope: None,
|
||||||
requested_env: Vec::new(),
|
requested_env: Vec::new(),
|
||||||
@@ -1766,7 +1767,9 @@ fn sign_native_user_auth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match load_first_native_identity(config, cli_identity, ssh_config) {
|
match load_first_native_identity(config, cli_identity, ssh_config) {
|
||||||
Ok(identity) => sign_user_auth(&identity, hello, server_hello, requested_forwardings),
|
Ok(identity) => {
|
||||||
|
sign_user_auth_with_private_key(&identity, hello, server_hello, requested_forwardings)
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
errors.push(format!("identity files: {err:#}"));
|
errors.push(format!("identity files: {err:#}"));
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
@@ -1781,7 +1784,7 @@ fn load_first_native_identity(
|
|||||||
config: &dosh::config::ClientConfig,
|
config: &dosh::config::ClientConfig,
|
||||||
cli_identity: Option<&Path>,
|
cli_identity: Option<&Path>,
|
||||||
ssh_config: &SshConfig,
|
ssh_config: &SshConfig,
|
||||||
) -> Result<ed25519_dalek::SigningKey> {
|
) -> Result<ssh_key::PrivateKey> {
|
||||||
load_first_native_identity_with_prompt(
|
load_first_native_identity_with_prompt(
|
||||||
config,
|
config,
|
||||||
cli_identity,
|
cli_identity,
|
||||||
@@ -1795,7 +1798,7 @@ fn load_first_native_identity_with_prompt<F>(
|
|||||||
cli_identity: Option<&Path>,
|
cli_identity: Option<&Path>,
|
||||||
ssh_config: &SshConfig,
|
ssh_config: &SshConfig,
|
||||||
mut prompt: F,
|
mut prompt: F,
|
||||||
) -> Result<ed25519_dalek::SigningKey>
|
) -> Result<ssh_key::PrivateKey>
|
||||||
where
|
where
|
||||||
F: FnMut(&Path) -> Result<String>,
|
F: FnMut(&Path) -> Result<String>,
|
||||||
{
|
{
|
||||||
@@ -1817,11 +1820,11 @@ where
|
|||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match load_ed25519_identity(&path) {
|
match load_native_identity(&path) {
|
||||||
Ok(identity) => return Ok(identity),
|
Ok(identity) => return Ok(identity),
|
||||||
Err(err) if err.to_string().contains(" is encrypted") => {
|
Err(err) if err.to_string().contains(" is encrypted") => {
|
||||||
match prompt(&path).and_then(|passphrase| {
|
match prompt(&path).and_then(|passphrase| {
|
||||||
load_ed25519_identity_with_passphrase(&path, Some(&passphrase))
|
load_native_identity_with_passphrase(&path, Some(&passphrase))
|
||||||
}) {
|
}) {
|
||||||
Ok(identity) => return Ok(identity),
|
Ok(identity) => return Ok(identity),
|
||||||
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
|
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
|
||||||
@@ -1831,10 +1834,10 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
Err(anyhow!("no usable ssh-ed25519 identity found"))
|
Err(anyhow!("no usable native identity found"))
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no usable ssh-ed25519 identity found: {}",
|
"no usable native identity found: {}",
|
||||||
errors.join("; ")
|
errors.join("; ")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -4222,8 +4225,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
VerifyingKey::from(&loaded).to_bytes(),
|
loaded.public_key().key_data().ed25519().unwrap().as_ref(),
|
||||||
VerifyingKey::from(&signing).to_bytes()
|
VerifyingKey::from(&signing).as_bytes()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4243,7 +4246,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
|
assert!(err.to_string().contains("no usable native identity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4263,7 +4266,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
|
assert!(err.to_string().contains("no usable native identity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4285,8 +4288,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
VerifyingKey::from(&loaded).to_bytes(),
|
loaded.public_key().key_data().ed25519().unwrap().as_ref(),
|
||||||
VerifyingKey::from(&signing).to_bytes()
|
VerifyingKey::from(&signing).as_bytes()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+94
-2
@@ -878,9 +878,11 @@ async fn handle_native_client_hello(
|
|||||||
.hello
|
.hello
|
||||||
.supported_user_key_algorithms
|
.supported_user_key_algorithms
|
||||||
.iter()
|
.iter()
|
||||||
.any(|algorithm| algorithm == "ssh-ed25519")
|
.any(|algorithm| dosh::native::is_supported_user_signature_algorithm(algorithm))
|
||||||
{
|
{
|
||||||
Err(anyhow!("native auth requires ssh-ed25519"))
|
Err(anyhow!(
|
||||||
|
"native auth requires a supported user key algorithm"
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
|
let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
|
||||||
if req.hello.requested_user != current_user {
|
if req.hello.requested_user != current_user {
|
||||||
@@ -3266,6 +3268,96 @@ mod tests {
|
|||||||
assert_eq!(data.bytes, b"hello");
|
assert_eq!(data.bytes, b"hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn blocked_stream_data_does_not_block_terminal_frames() {
|
||||||
|
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
|
||||||
|
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
|
||||||
|
let client_id = [8u8; 16];
|
||||||
|
let session_key = [10u8; 32];
|
||||||
|
let receiver = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let sender = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
|
||||||
|
let endpoint = receiver.local_addr().unwrap();
|
||||||
|
state.sessions.insert(
|
||||||
|
"test".to_string(),
|
||||||
|
Session {
|
||||||
|
pty: None,
|
||||||
|
parser: vt100::Parser::new(24, 80, 100),
|
||||||
|
clients: HashMap::from([(
|
||||||
|
client_id,
|
||||||
|
ClientState {
|
||||||
|
endpoint,
|
||||||
|
mode: "read-write".to_string(),
|
||||||
|
session_key,
|
||||||
|
last_acked: 0,
|
||||||
|
replay: ReplayWindow::default(),
|
||||||
|
send_seq: 1,
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
last_seen: Instant::now(),
|
||||||
|
pending: VecDeque::new(),
|
||||||
|
allowed_forwardings: Vec::new(),
|
||||||
|
stream_writers: HashMap::new(),
|
||||||
|
opened_streams: HashSet::from([42]),
|
||||||
|
stream_send_credit: HashMap::from([(42, 0)]),
|
||||||
|
stream_pending_data: HashMap::new(),
|
||||||
|
epoch: 0,
|
||||||
|
epoch_started: Instant::now(),
|
||||||
|
epoch_packets: 0,
|
||||||
|
previous_session_key: None,
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
output_seq: 0,
|
||||||
|
recent: VecDeque::new(),
|
||||||
|
empty_since: None,
|
||||||
|
holder_control: None,
|
||||||
|
persistent: false,
|
||||||
|
bytes_since_persist: 0,
|
||||||
|
last_persisted_seq: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.client_index.insert(client_id, "test".to_string());
|
||||||
|
let state = Arc::new(Mutex::new(state));
|
||||||
|
|
||||||
|
queue_or_send_stream_data_to_client(
|
||||||
|
&state,
|
||||||
|
&sender,
|
||||||
|
client_id,
|
||||||
|
42,
|
||||||
|
b"blocked-forward-bytes".to_vec(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let locked = state.lock().unwrap();
|
||||||
|
let client = locked.sessions["test"].clients.get(&client_id).unwrap();
|
||||||
|
assert_eq!(client.stream_pending_data[&42].len(), 1);
|
||||||
|
assert_eq!(client.stream_send_credit[&42], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast_output(
|
||||||
|
&state,
|
||||||
|
&sender,
|
||||||
|
PtyOutput {
|
||||||
|
session: "test".to_string(),
|
||||||
|
bytes: b"terminal-priority".to_vec(),
|
||||||
|
exited: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let packet = protocol::decode(&buf[..n]).unwrap();
|
||||||
|
assert_eq!(packet.header.kind, PacketKind::Frame);
|
||||||
|
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
|
||||||
|
let frame: Frame = protocol::from_body(&plain).unwrap();
|
||||||
|
assert_eq!(frame.bytes, b"terminal-priority");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_non_loopback_bind_requires_explicit_config() {
|
fn remote_non_loopback_bind_requires_explicit_config() {
|
||||||
let config = ServerConfig::default();
|
let config = ServerConfig::default();
|
||||||
|
|||||||
+340
-26
@@ -5,7 +5,9 @@ use anyhow::{Context, Result, bail};
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
use rsa::traits::PublicKeyParts;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
@@ -339,6 +341,92 @@ pub fn sign_user_auth(
|
|||||||
Ok(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(
|
pub fn verify_native_user_auth(
|
||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
server: &NativeServerHello,
|
server: &NativeServerHello,
|
||||||
@@ -347,37 +435,127 @@ pub fn verify_native_user_auth(
|
|||||||
source_ip: Option<IpAddr>,
|
source_ip: Option<IpAddr>,
|
||||||
) -> Result<AuthorizedKey> {
|
) -> Result<AuthorizedKey> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
auth.public_key_algorithm == "ssh-ed25519",
|
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
|
||||||
"unsupported native user key algorithm {}",
|
"unsupported native user key algorithm {}",
|
||||||
auth.public_key_algorithm
|
auth.public_key_algorithm
|
||||||
);
|
);
|
||||||
anyhow::ensure!(
|
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
|
||||||
auth.public_key.len() == 32,
|
|
||||||
"ssh-ed25519 public key must be 32 bytes"
|
|
||||||
);
|
|
||||||
anyhow::ensure!(
|
|
||||||
auth.signature.len() == 64,
|
|
||||||
"ssh-ed25519 signature must be 64 bytes"
|
|
||||||
);
|
|
||||||
let authorized = authorized_keys
|
let authorized = authorized_keys
|
||||||
.iter()
|
.iter()
|
||||||
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
|
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
|
||||||
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
|
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
|
||||||
authorized.ensure_native_allowed(auth, source_ip)?;
|
authorized.ensure_native_allowed(auth, source_ip)?;
|
||||||
|
|
||||||
let mut public_key = [0u8; 32];
|
|
||||||
public_key.copy_from_slice(&auth.public_key);
|
|
||||||
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
|
|
||||||
let mut signature = [0u8; 64];
|
|
||||||
signature.copy_from_slice(&auth.signature);
|
|
||||||
let signature = Signature::from_bytes(&signature);
|
|
||||||
let transcript = user_auth_transcript(client, server, auth)?;
|
let transcript = user_auth_transcript(client, server, auth)?;
|
||||||
verifying_key
|
verify_native_user_signature(auth, &transcript)?;
|
||||||
.verify(&transcript, &signature)
|
|
||||||
.context("verify native user signature")?;
|
|
||||||
Ok(authorized.clone())
|
Ok(authorized.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn supported_user_key_algorithms() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"ssh-ed25519".to_string(),
|
||||||
|
"ecdsa-sha2-nistp256".to_string(),
|
||||||
|
"rsa-sha2-512".to_string(),
|
||||||
|
"rsa-sha2-256".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
|
||||||
|
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
algorithm,
|
||||||
|
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
|
||||||
|
match signature_algorithm {
|
||||||
|
"ssh-ed25519" => Ok("ssh-ed25519"),
|
||||||
|
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
|
||||||
|
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
|
||||||
|
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
|
||||||
|
match auth.public_key_algorithm.as_str() {
|
||||||
|
"ssh-ed25519" => {
|
||||||
|
anyhow::ensure!(
|
||||||
|
auth.public_key.len() == 32,
|
||||||
|
"ssh-ed25519 public key must be 32 bytes"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
auth.signature.len() == 64,
|
||||||
|
"ssh-ed25519 signature must be 64 bytes"
|
||||||
|
);
|
||||||
|
let mut public_key = [0u8; 32];
|
||||||
|
public_key.copy_from_slice(&auth.public_key);
|
||||||
|
let verifying_key =
|
||||||
|
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
|
||||||
|
let mut signature = [0u8; 64];
|
||||||
|
signature.copy_from_slice(&auth.signature);
|
||||||
|
let signature = Signature::from_bytes(&signature);
|
||||||
|
verifying_key
|
||||||
|
.verify(transcript, &signature)
|
||||||
|
.context("verify native user signature")?;
|
||||||
|
}
|
||||||
|
"ecdsa-sha2-nistp256" => {
|
||||||
|
let public_key = ssh_public_key_from_blob(&auth.public_key)
|
||||||
|
.context("parse native user SSH public key")?;
|
||||||
|
let algorithm =
|
||||||
|
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
|
||||||
|
format!("parse signature algorithm {}", auth.public_key_algorithm)
|
||||||
|
})?;
|
||||||
|
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
|
||||||
|
.context("parse native user SSH signature")?;
|
||||||
|
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
|
||||||
|
.context("verify native user SSH signature")?;
|
||||||
|
}
|
||||||
|
"rsa-sha2-512" | "rsa-sha2-256" => {
|
||||||
|
verify_rsa_sha2_signature(
|
||||||
|
&auth.public_key,
|
||||||
|
&auth.public_key_algorithm,
|
||||||
|
transcript,
|
||||||
|
&auth.signature,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_rsa_sha2_signature(
|
||||||
|
public_key_blob: &[u8],
|
||||||
|
signature_algorithm: &str,
|
||||||
|
transcript: &[u8],
|
||||||
|
signature: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
|
||||||
|
let public = rsa::RsaPublicKey::new(
|
||||||
|
rsa::BigUint::from_bytes_be(n),
|
||||||
|
rsa::BigUint::from_bytes_be(e),
|
||||||
|
)
|
||||||
|
.context("parse RSA public key")?;
|
||||||
|
let signature =
|
||||||
|
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
|
||||||
|
match signature_algorithm {
|
||||||
|
"rsa-sha2-256" => {
|
||||||
|
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
|
||||||
|
SignatureVerifier::verify(&key, transcript, &signature)
|
||||||
|
.context("verify RSA-SHA256 signature")
|
||||||
|
}
|
||||||
|
"rsa-sha2-512" => {
|
||||||
|
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
|
||||||
|
SignatureVerifier::verify(&key, transcript, &signature)
|
||||||
|
.context("verify RSA-SHA512 signature")
|
||||||
|
}
|
||||||
|
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn verify_native_user_auth_from_config(
|
pub fn verify_native_user_auth_from_config(
|
||||||
config: &ServerConfig,
|
config: &ServerConfig,
|
||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
@@ -470,6 +648,35 @@ pub fn load_ed25519_identity_with_passphrase(
|
|||||||
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
|
.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 {
|
impl AuthorizedKey {
|
||||||
fn ensure_native_allowed(
|
fn ensure_native_allowed(
|
||||||
&self,
|
&self,
|
||||||
@@ -565,7 +772,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
|
|||||||
fields.len() >= 2,
|
fields.len() >= 2,
|
||||||
"authorized_keys:{line_number}: expected key fields"
|
"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)
|
(AuthorizedKeyOptions::default(), 0)
|
||||||
} else {
|
} else {
|
||||||
(parse_authorized_key_options(&fields[0])?, 1)
|
(parse_authorized_key_options(&fields[0])?, 1)
|
||||||
@@ -576,15 +783,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
|
|||||||
let key_blob = fields
|
let key_blob = fields
|
||||||
.get(key_type_index + 1)
|
.get(key_type_index + 1)
|
||||||
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
|
.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
|
let decoded = STANDARD
|
||||||
.decode(key_blob)
|
.decode(key_blob)
|
||||||
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
|
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
|
||||||
let key = parse_ssh_ed25519_public_blob(&decoded)
|
anyhow::ensure!(
|
||||||
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
|
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 {
|
let comment = if fields.len() > key_type_index + 2 {
|
||||||
Some(fields[key_type_index + 2..].join(" "))
|
Some(fields[key_type_index + 2..].join(" "))
|
||||||
} else {
|
} else {
|
||||||
@@ -598,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> {
|
fn split_authorized_key_fields(line: &str) -> Vec<String> {
|
||||||
line.split_whitespace().map(ToString::to_string).collect()
|
line.split_whitespace().map(ToString::to_string).collect()
|
||||||
}
|
}
|
||||||
@@ -765,6 +1022,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
|
|||||||
Ok(out)
|
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> {
|
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
write_ssh_string(&mut out, b"ssh-ed25519");
|
write_ssh_string(&mut out, b"ssh-ed25519");
|
||||||
@@ -772,6 +1041,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
|
|||||||
out
|
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]> {
|
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
||||||
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
|
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
|
||||||
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
|
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
|
||||||
@@ -1084,6 +1359,45 @@ mod tests {
|
|||||||
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
|
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]
|
#[test]
|
||||||
fn native_user_auth_rejects_tampered_transcript() {
|
fn native_user_auth_rejects_tampered_transcript() {
|
||||||
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
|
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
|
||||||
|
|||||||
+59
-21
@@ -1,6 +1,6 @@
|
|||||||
use crate::native::{
|
use crate::native::{
|
||||||
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
|
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 anyhow::{Context, Result, anyhow, bail};
|
||||||
use std::io::{Read, Write};
|
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_AGENT_IDENTITIES_ANSWER: u8 = 12;
|
||||||
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
|
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
|
||||||
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
|
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
|
||||||
|
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
|
||||||
const MAX_AGENT_PACKET: usize = 256 * 1024;
|
const MAX_AGENT_PACKET: usize = 256 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AgentIdentity {
|
pub struct AgentIdentity {
|
||||||
pub key_blob: Vec<u8>,
|
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,
|
pub comment: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +42,13 @@ pub fn sign_user_auth_with_agent_at(
|
|||||||
) -> Result<NativeUserAuth> {
|
) -> Result<NativeUserAuth> {
|
||||||
let mut agent = UnixStream::connect(socket_path.as_ref())
|
let mut agent = UnixStream::connect(socket_path.as_ref())
|
||||||
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
|
.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
|
let identity = identities
|
||||||
.first()
|
.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 {
|
let mut auth = NativeUserAuth {
|
||||||
public_key_algorithm: "ssh-ed25519".to_string(),
|
public_key_algorithm: identity.sign_algorithm.clone(),
|
||||||
public_key: identity.public_key.to_vec(),
|
public_key: identity.public_key.clone(),
|
||||||
signature: Vec::new(),
|
signature: Vec::new(),
|
||||||
requested_forwardings,
|
requested_forwardings,
|
||||||
};
|
};
|
||||||
@@ -54,7 +58,7 @@ pub fn sign_user_auth_with_agent_at(
|
|||||||
Ok(auth)
|
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])?;
|
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
|
||||||
let payload = read_agent_packet(agent)?;
|
let payload = read_agent_packet(agent)?;
|
||||||
let mut cursor = payload.as_slice();
|
let mut cursor = payload.as_slice();
|
||||||
@@ -72,18 +76,49 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
|
|||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
|
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
|
||||||
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
|
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) {
|
if let Some(identity) = supported_identity(key_blob, comment)? {
|
||||||
identities.push(AgentIdentity {
|
identities.push(identity);
|
||||||
key_blob,
|
|
||||||
public_key,
|
|
||||||
comment,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
|
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
|
||||||
Ok(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(
|
fn sign_with_agent(
|
||||||
agent: &mut UnixStream,
|
agent: &mut UnixStream,
|
||||||
identity: &AgentIdentity,
|
identity: &AgentIdentity,
|
||||||
@@ -93,7 +128,7 @@ fn sign_with_agent(
|
|||||||
request.push(SSH2_AGENTC_SIGN_REQUEST);
|
request.push(SSH2_AGENTC_SIGN_REQUEST);
|
||||||
write_ssh_string(&mut request, &identity.key_blob);
|
write_ssh_string(&mut request, &identity.key_blob);
|
||||||
write_ssh_string(&mut request, transcript);
|
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)?;
|
write_agent_packet(agent, &request)?;
|
||||||
|
|
||||||
let payload = read_agent_packet(agent)?;
|
let payload = read_agent_packet(agent)?;
|
||||||
@@ -114,20 +149,17 @@ fn sign_with_agent(
|
|||||||
|
|
||||||
let mut signature_cursor = signature_blob;
|
let mut signature_cursor = signature_blob;
|
||||||
let algorithm = read_ssh_string(&mut signature_cursor)?;
|
let algorithm = read_ssh_string(&mut signature_cursor)?;
|
||||||
|
let algorithm = String::from_utf8_lossy(algorithm);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
algorithm == b"ssh-ed25519",
|
algorithm == identity.sign_algorithm,
|
||||||
"ssh-agent returned unsupported signature algorithm {}",
|
"ssh-agent returned signature algorithm {algorithm}, expected {}",
|
||||||
String::from_utf8_lossy(algorithm)
|
identity.sign_algorithm
|
||||||
);
|
);
|
||||||
let signature = read_ssh_string(&mut signature_cursor)?;
|
let signature = read_ssh_string(&mut signature_cursor)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
signature_cursor.is_empty(),
|
signature_cursor.is_empty(),
|
||||||
"trailing data in ssh-agent signature blob"
|
"trailing data in ssh-agent signature blob"
|
||||||
);
|
);
|
||||||
anyhow::ensure!(
|
|
||||||
signature.len() == 64,
|
|
||||||
"ssh-agent Ed25519 signature was not 64 bytes"
|
|
||||||
);
|
|
||||||
Ok(signature.to_vec())
|
Ok(signature.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +214,12 @@ fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
|
|||||||
out.extend_from_slice(value);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+334
-1
@@ -4,6 +4,7 @@ use std::net::{TcpListener, TcpStream, UdpSocket};
|
|||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -234,6 +235,37 @@ fn start_echo_server() -> u16 {
|
|||||||
port
|
port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_slow_sink_server() -> u16 {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
thread::spawn(move || {
|
||||||
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(_) => thread::sleep(Duration::from_millis(20)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_with_retry_no_child(port: u16, timeout: Duration) -> TcpStream {
|
||||||
|
let deadline = std::time::Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
match TcpStream::connect(("127.0.0.1", port)) {
|
||||||
|
Ok(stream) => return stream,
|
||||||
|
Err(err) if std::time::Instant::now() < deadline => {
|
||||||
|
let _ = err;
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(err) => panic!("connect to local forward 127.0.0.1:{port}: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
|
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
|
||||||
write_native_client_auth_with_options(dir, config_path, "");
|
write_native_client_auth_with_options(dir, config_path, "");
|
||||||
}
|
}
|
||||||
@@ -414,6 +446,15 @@ fn direct_attach(
|
|||||||
config: &std::path::Path,
|
config: &std::path::Path,
|
||||||
port: u16,
|
port: u16,
|
||||||
mode: &str,
|
mode: &str,
|
||||||
|
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
|
||||||
|
direct_attach_session(config, port, mode, "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_attach_session(
|
||||||
|
config: &std::path::Path,
|
||||||
|
port: u16,
|
||||||
|
mode: &str,
|
||||||
|
session: &str,
|
||||||
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
|
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
|
||||||
let config = load_server_config(Some(config.to_path_buf())).unwrap();
|
let config = load_server_config(Some(config.to_path_buf())).unwrap();
|
||||||
let secret = load_or_create_server_secret(&config).unwrap();
|
let secret = load_or_create_server_secret(&config).unwrap();
|
||||||
@@ -421,7 +462,7 @@ fn direct_attach(
|
|||||||
&config,
|
&config,
|
||||||
&secret,
|
&secret,
|
||||||
"tester".to_string(),
|
"tester".to_string(),
|
||||||
"default".to_string(),
|
session.to_string(),
|
||||||
mode.to_string(),
|
mode.to_string(),
|
||||||
(80, 24),
|
(80, 24),
|
||||||
crypto::random_12(),
|
crypto::random_12(),
|
||||||
@@ -782,6 +823,106 @@ fn native_local_forward_background_smoke() {
|
|||||||
assert_eq!(&buf[..n], b"dosh-background-forward-ping");
|
assert_eq!(&buf[..n], b"dosh-background-forward-ping");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_local_forward_bulk_load_does_not_delay_interactive_terminal() {
|
||||||
|
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
write_native_client_auth(&dir, &config);
|
||||||
|
let sink_port = start_slow_sink_server();
|
||||||
|
let local_port = free_tcp_port();
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
|
||||||
|
let pty = NativePtySystem::default();
|
||||||
|
let pair = pty
|
||||||
|
.openpty(PtySize {
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut reader = pair.master.try_clone_reader().unwrap();
|
||||||
|
let mut writer = pair.master.take_writer().unwrap();
|
||||||
|
|
||||||
|
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
|
||||||
|
let mut cmd = CommandBuilder::new(client_bin);
|
||||||
|
let forward_arg = format!("{local_port}:127.0.0.1:{sink_port}");
|
||||||
|
let dosh_port = port.to_string();
|
||||||
|
cmd.args([
|
||||||
|
"--auth",
|
||||||
|
"native",
|
||||||
|
"--no-cache",
|
||||||
|
"-L",
|
||||||
|
&forward_arg,
|
||||||
|
"--session",
|
||||||
|
"load-session",
|
||||||
|
"--dosh-host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--dosh-port",
|
||||||
|
&dosh_port,
|
||||||
|
"local",
|
||||||
|
]);
|
||||||
|
cmd.env("HOME", dir.path().to_string_lossy().to_string());
|
||||||
|
let mut child = pair.slave.spawn_command(cmd).unwrap();
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
let mut forward_stream = connect_with_retry_no_child(local_port, Duration::from_secs(5));
|
||||||
|
let bulk_writer = thread::spawn(move || {
|
||||||
|
let chunk = vec![b'x'; 8192];
|
||||||
|
for _ in 0..128 {
|
||||||
|
if forward_stream.write_all(&chunk).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (seen_tx, seen_rx) = mpsc::channel();
|
||||||
|
let reader_handle = thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let mut output = Vec::new();
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
output.extend_from_slice(&buf[..n]);
|
||||||
|
if output
|
||||||
|
.windows(b"DOSH_LOAD_TERMINAL_OK".len())
|
||||||
|
.any(|w| w == b"DOSH_LOAD_TERMINAL_OK")
|
||||||
|
{
|
||||||
|
let _ = seen_tx.send(String::from_utf8_lossy(&output).to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
writer
|
||||||
|
.write_all(b"printf DOSH_LOAD_TERMINAL_OK\\n\r")
|
||||||
|
.unwrap();
|
||||||
|
writer.flush().unwrap();
|
||||||
|
|
||||||
|
let seen = seen_rx.recv_timeout(Duration::from_secs(3));
|
||||||
|
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
drop(writer);
|
||||||
|
drop(pair.master);
|
||||||
|
let _ = reader_handle.join();
|
||||||
|
let _ = bulk_writer.join();
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
seen.is_ok(),
|
||||||
|
"interactive terminal output was delayed behind local-forward bulk load"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_remote_forward_echo_smoke() {
|
fn native_remote_forward_echo_smoke() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1180,6 +1321,67 @@ fn live_output_forwards_terminal_control_sequences() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_control_sequences_survive_transport_verbatim() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
|
||||||
|
|
||||||
|
let sequences = concat!(
|
||||||
|
"\x1b[?1049h", // alternate screen on
|
||||||
|
"\x1b[?2004h", // bracketed paste on
|
||||||
|
"\x1b[?1000h", // mouse tracking on
|
||||||
|
"\x1b[?1006h", // SGR mouse encoding on
|
||||||
|
"\x1b[?25l", // cursor hidden
|
||||||
|
"\x1b[12;34H", // absolute cursor movement
|
||||||
|
"DOSH_TUI_VERBATIM",
|
||||||
|
"\x1b[?25h",
|
||||||
|
"\x1b[?1006l",
|
||||||
|
"\x1b[?1000l",
|
||||||
|
"\x1b[?2004l",
|
||||||
|
"\x1b[?1049l"
|
||||||
|
);
|
||||||
|
let input = Input {
|
||||||
|
bytes: format!("printf '{sequences}'\n").into_bytes(),
|
||||||
|
};
|
||||||
|
send_encrypted(
|
||||||
|
&socket,
|
||||||
|
port,
|
||||||
|
PacketKind::Input,
|
||||||
|
ok.client_id,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
&bootstrap.session_key,
|
||||||
|
&protocol::to_body(&input).unwrap(),
|
||||||
|
);
|
||||||
|
let text = collect_frame_text(&socket, &bootstrap.session_key, 2000);
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
for needle in [
|
||||||
|
"\x1b[?1049h",
|
||||||
|
"\x1b[?2004h",
|
||||||
|
"\x1b[?1000h",
|
||||||
|
"\x1b[?1006h",
|
||||||
|
"\x1b[?25l",
|
||||||
|
"\x1b[12;34H",
|
||||||
|
"DOSH_TUI_VERBATIM",
|
||||||
|
"\x1b[?25h",
|
||||||
|
"\x1b[?1006l",
|
||||||
|
"\x1b[?1000l",
|
||||||
|
"\x1b[?2004l",
|
||||||
|
"\x1b[?1049l",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
text.contains(needle),
|
||||||
|
"expected TUI control sequence {needle:?} to survive verbatim, got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resume_snapshot_preserves_alternate_screen_mode() {
|
fn resume_snapshot_preserves_alternate_screen_mode() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1983,3 +2185,134 @@ fn session_survives_server_restart_same_shell_and_screen() {
|
|||||||
"restored attach snapshot must repaint the pre-restart screen, got {snapshot:?}"
|
"restored attach snapshot must repaint the pre-restart screen, got {snapshot:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_persistent_named_sessions_survive_restart_independently() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sessions_dir = dir.path().join("sessions");
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_persistent_server_config(&dir, port);
|
||||||
|
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
|
||||||
|
let (work_socket, work_bootstrap, work_ok) =
|
||||||
|
direct_attach_session(&config, port, "read-write", "work");
|
||||||
|
let (logs_socket, logs_bootstrap, logs_ok) =
|
||||||
|
direct_attach_session(&config, port, "read-write", "logs");
|
||||||
|
let work_key = work_bootstrap.session_key;
|
||||||
|
let logs_key = logs_bootstrap.session_key;
|
||||||
|
|
||||||
|
type_line(
|
||||||
|
&work_socket,
|
||||||
|
port,
|
||||||
|
&work_ok,
|
||||||
|
&work_key,
|
||||||
|
2,
|
||||||
|
"export DOSH_SLOT=work_slot\n",
|
||||||
|
);
|
||||||
|
type_line(
|
||||||
|
&logs_socket,
|
||||||
|
port,
|
||||||
|
&logs_ok,
|
||||||
|
&logs_key,
|
||||||
|
2,
|
||||||
|
"export DOSH_SLOT=logs_slot\n",
|
||||||
|
);
|
||||||
|
type_line(
|
||||||
|
&work_socket,
|
||||||
|
port,
|
||||||
|
&work_ok,
|
||||||
|
&work_key,
|
||||||
|
3,
|
||||||
|
"printf 'WORK_SCREEN_MARKER\\n'\n",
|
||||||
|
);
|
||||||
|
type_line(
|
||||||
|
&logs_socket,
|
||||||
|
port,
|
||||||
|
&logs_ok,
|
||||||
|
&logs_key,
|
||||||
|
3,
|
||||||
|
"printf 'LOGS_SCREEN_MARKER\\n'\n",
|
||||||
|
);
|
||||||
|
let work_pre = collect_frame_text(&work_socket, &work_key, 1500);
|
||||||
|
let logs_pre = collect_frame_text(&logs_socket, &logs_key, 1500);
|
||||||
|
assert!(
|
||||||
|
work_pre.contains("WORK_SCREEN_MARKER"),
|
||||||
|
"work marker missing before restart: {work_pre:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
logs_pre.contains("LOGS_SCREEN_MARKER"),
|
||||||
|
"logs marker missing before restart: {logs_pre:?}"
|
||||||
|
);
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
|
||||||
|
let work_pid_before = holder_shell_pid(&sessions_dir, "work");
|
||||||
|
let logs_pid_before = holder_shell_pid(&sessions_dir, "logs");
|
||||||
|
assert!(work_pid_before.is_some(), "work holder pid missing");
|
||||||
|
assert!(logs_pid_before.is_some(), "logs holder pid missing");
|
||||||
|
assert_ne!(
|
||||||
|
work_pid_before, logs_pid_before,
|
||||||
|
"named sessions must have independent holder shells"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let (work_socket2, work_bootstrap2, work_ok2) =
|
||||||
|
direct_attach_session(&config, port, "read-write", "work");
|
||||||
|
let (logs_socket2, logs_bootstrap2, logs_ok2) =
|
||||||
|
direct_attach_session(&config, port, "read-write", "logs");
|
||||||
|
let work_key2 = work_bootstrap2.session_key;
|
||||||
|
let logs_key2 = logs_bootstrap2.session_key;
|
||||||
|
let work_snapshot = String::from_utf8_lossy(&work_ok2.snapshot).to_string();
|
||||||
|
let logs_snapshot = String::from_utf8_lossy(&logs_ok2.snapshot).to_string();
|
||||||
|
|
||||||
|
type_line(
|
||||||
|
&work_socket2,
|
||||||
|
port,
|
||||||
|
&work_ok2,
|
||||||
|
&work_key2,
|
||||||
|
2,
|
||||||
|
"printf 'WORK_SLOT=%s\\n' \"$DOSH_SLOT\"\n",
|
||||||
|
);
|
||||||
|
type_line(
|
||||||
|
&logs_socket2,
|
||||||
|
port,
|
||||||
|
&logs_ok2,
|
||||||
|
&logs_key2,
|
||||||
|
2,
|
||||||
|
"printf 'LOGS_SLOT=%s\\n' \"$DOSH_SLOT\"\n",
|
||||||
|
);
|
||||||
|
let work_post = collect_frame_text(&work_socket2, &work_key2, 2000);
|
||||||
|
let logs_post = collect_frame_text(&logs_socket2, &logs_key2, 2000);
|
||||||
|
let work_pid_after = holder_shell_pid(&sessions_dir, "work");
|
||||||
|
let logs_pid_after = holder_shell_pid(&sessions_dir, "logs");
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
kill_holder(&sessions_dir, "work");
|
||||||
|
kill_holder(&sessions_dir, "logs");
|
||||||
|
|
||||||
|
assert_eq!(work_pid_after, work_pid_before);
|
||||||
|
assert_eq!(logs_pid_after, logs_pid_before);
|
||||||
|
assert!(
|
||||||
|
work_snapshot.contains("WORK_SCREEN_MARKER")
|
||||||
|
&& !work_snapshot.contains("LOGS_SCREEN_MARKER"),
|
||||||
|
"work snapshot should restore only work screen, got {work_snapshot:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
logs_snapshot.contains("LOGS_SCREEN_MARKER")
|
||||||
|
&& !logs_snapshot.contains("WORK_SCREEN_MARKER"),
|
||||||
|
"logs snapshot should restore only logs screen, got {logs_snapshot:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
work_post.contains("WORK_SLOT=work_slot") && !work_post.contains("logs_slot"),
|
||||||
|
"work state crossed sessions or disappeared: {work_post:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
logs_post.contains("LOGS_SLOT=logs_slot") && !logs_post.contains("work_slot"),
|
||||||
|
"logs state crossed sessions or disappeared: {logs_post:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user