From e7f3679fc18b2dca39f0de25190e30971efcb830 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:54:24 -0400 Subject: [PATCH] Wire native Dosh auth runtime --- Cargo.lock | 318 +++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/auth.rs | 2 +- src/bin/dosh-client.rs | 306 ++++++++++++++++++++++++++++++++++- src/bin/dosh-server.rs | 269 ++++++++++++++++++++++++++++++- src/native.rs | 102 +++++++++++- src/protocol.rs | 29 ++++ tests/integration_smoke.rs | 69 ++++++++ 8 files changed, 1086 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4613e3c..36dcdc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -255,6 +261,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -310,6 +328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -354,10 +373,12 @@ dependencies = [ "rand", "serde", "sha2", + "ssh-key", "tempfile", "tokio", "toml", "vt100", + "x25519-dalek", ] [[package]] @@ -366,6 +387,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -390,6 +425,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -412,6 +466,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -443,6 +507,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -469,6 +534,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -561,6 +637,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -574,6 +653,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -651,6 +736,52 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -675,6 +806,44 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -698,6 +867,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -710,6 +888,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -771,6 +960,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -845,6 +1043,37 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -886,6 +1115,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1050,6 +1293,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core", ] @@ -1069,6 +1313,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" @@ -1079,6 +1329,48 @@ dependencies = [ "der", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1638,6 +1930,18 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -1663,6 +1967,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 90ced13..aefccd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,11 @@ portable-pty = "0.8" rand = "0.8" serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" +ssh-key = { version = "0.6.7", features = ["ed25519"] } tokio = { version = "1.41", features = ["full"] } toml = "0.8" vt100 = "0.15" +x25519-dalek = { version = "2.0.1", features = ["static_secrets", "getrandom"] } [dev-dependencies] tempfile = "3.14" diff --git a/src/auth.rs b/src/auth.rs index 7dd06c4..92a0a0c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -202,7 +202,7 @@ pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result, + #[arg(long)] no_cache: bool, #[arg(long)] remove: bool, @@ -237,6 +242,66 @@ async fn main() -> Result<()> { } } + let auth_preference = args + .auth + .clone() + .unwrap_or_else(|| config.auth_preference.clone()); + if !args.local_auth && auth_allows(&auth_preference, "native") { + let native_start = Instant::now(); + match try_native_auth( + &socket, + &config, + &requested_server, + &server, + ssh_port, + &target_udp_host, + dosh_port, + args.ssh_key.as_deref(), + &session, + &mode, + cols, + rows, + ) + .await + { + Ok((frame, cred)) => { + log_timing(args.verbose, "native_auth", native_start.elapsed()); + if !args.no_cache { + save_cache(&cache_path, &cred)?; + } + log_timing(args.verbose, "terminal_ready", started.elapsed()); + if args.attach_only { + render_frame(&frame)?; + detach_once(&socket, &cred, 2).await?; + return Ok(()); + } + return run_terminal( + socket, + cred, + Some(frame), + predict, + startup_command, + config.reconnect_timeout_secs, + ) + .await; + } + Err(err) if auth_allows(&auth_preference, "ssh") => { + log_debug( + args.verbose, + 2, + &format!("dosh native auth failed, falling back to SSH bootstrap: {err:#}"), + ); + } + Err(err) => return Err(err.context("native auth failed")), + } + } + + if !args.local_auth && !auth_allows(&auth_preference, "ssh") { + return Err(anyhow!( + "auth preference {auth_preference:?} does not allow SSH fallback" + )); + } + let bootstrap_start = Instant::now(); let bootstrap = if args.local_auth { local_bootstrap(&session, &mode, cols, rows, dosh_port, target_udp_host)? @@ -565,6 +630,13 @@ fn local_bootstrap( ) } +fn auth_allows(preference: &str, method: &str) -> bool { + preference + .split(',') + .map(str::trim) + .any(|entry| entry == "auto" || entry == method) +} + fn log_timing(verbose: u8, name: &str, elapsed: Duration) { if verbose >= 1 { eprintln!("dosh timing {name}={}ms", elapsed.as_millis()); @@ -663,6 +735,15 @@ fn ssh_destination_host(server: &str) -> String { .to_string() } +fn ssh_username(server: &str) -> Option { + let (user, _) = server.rsplit_once('@')?; + if user.is_empty() { + None + } else { + Some(user.to_string()) + } +} + fn ssh_config_hostname(server: &str, ssh_port: Option) -> Result { ssh_config(server, ssh_port)? .hostname @@ -673,6 +754,8 @@ fn ssh_config_hostname(server: &str, ssh_port: Option) -> Result { struct SshConfig { hostname: Option, port: Option, + user: Option, + identity_files: Vec, } fn ssh_config(server: &str, ssh_port: Option) -> Result { @@ -707,6 +790,10 @@ fn parse_ssh_config(raw: &str) -> SshConfig { config.hostname = Some(value.to_string()); } else if key.eq_ignore_ascii_case("port") { config.port = value.parse().ok(); + } else if key.eq_ignore_ascii_case("user") && !value.is_empty() { + config.user = Some(value.to_string()); + } else if key.eq_ignore_ascii_case("identityfile") && !value.is_empty() { + config.identity_files.push(value.to_string()); } } config @@ -720,6 +807,191 @@ fn resolve_addr(host: &str, port: u16) -> Result { .ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}")) } +async fn try_native_auth( + socket: &UdpSocket, + config: &dosh::config::ClientConfig, + requested_server: &str, + server: &str, + ssh_port: Option, + udp_host: &str, + udp_port: u16, + cli_identity: Option<&Path>, + session: &str, + mode: &str, + cols: u16, + rows: u16, +) -> Result<(Frame, CachedCredential)> { + let addr = resolve_addr(udp_host, udp_port)?; + let ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); + let requested_user = ssh_username(server) + .or(ssh_config.user.clone()) + .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())); + let (client_secret, client_public) = generate_native_ephemeral(); + let hello = dosh::native::NativeClientHello { + protocol_version: dosh::native::NATIVE_PROTOCOL_VERSION, + client_random: crypto::random_32(), + client_ephemeral_public: client_public, + requested_host: requested_server.to_string(), + requested_user, + requested_session: session.to_string(), + requested_mode: mode.to_string(), + terminal_size: (cols, rows), + supported_aead: vec!["chacha20poly1305".to_string()], + supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], + cached_host_key_fingerprint: None, + attach_ticket_envelope: None, + }; + let packet = protocol::encode_plain( + PacketKind::NativeClientHello, + [0u8; 16], + 1, + 0, + &protocol::to_body(&NativeClientHelloBody { + hello: hello.clone(), + })?, + )?; + socket.send_to(&packet, addr).await?; + + let mut buf = vec![0u8; 65535]; + let (n, _) = tokio::time::timeout( + Duration::from_millis(config.native_auth_timeout_ms.max(1)), + socket.recv_from(&mut buf), + ) + .await??; + let packet = protocol::decode(&buf[..n])?; + if packet.header.kind != PacketKind::NativeServerHello { + if packet.header.kind == PacketKind::AttachReject { + let reject: AttachReject = protocol::from_body(&packet.body)?; + return Err(anyhow!("native auth rejected: {}", reject.reason)); + } + return Err(anyhow!("native auth received unexpected server response")); + } + let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?; + verify_server_hello(&hello, &server_hello.hello)?; + + let known_hosts = expand_tilde(&config.known_hosts); + match verify_known_host(&known_hosts, requested_server, &server_hello.hello.host_key)? { + KnownHostStatus::Trusted => {} + KnownHostStatus::Unknown if config.trust_on_first_use => { + trust_host( + &known_hosts, + requested_server, + &server_hello.hello.host_key, + "tofu", + false, + )?; + } + KnownHostStatus::Unknown => { + return Err(anyhow!( + "Dosh host key for {requested_server} is not trusted; run `dosh trust {requested_server}` first" + )); + } + KnownHostStatus::Mismatch { expected, actual } => { + return Err(anyhow!( + "Dosh host key mismatch for {requested_server}: expected {expected}, got {actual}" + )); + } + } + + let session_key = derive_native_session_key( + &client_secret, + server_hello.hello.server_ephemeral_public, + &hello, + &server_hello.hello, + )?; + let identity = load_first_native_identity(config, cli_identity, &ssh_config)?; + let auth = sign_user_auth(&identity, &hello, &server_hello.hello, Vec::new())?; + let mut pending_id = [0u8; 16]; + pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]); + let auth_body = protocol::to_body(&NativeUserAuthBody { auth })?; + let packet = protocol::encode_encrypted( + PacketKind::NativeUserAuth, + pending_id, + 2, + 1, + &session_key, + CLIENT_TO_SERVER, + &auth_body, + )?; + socket.send_to(&packet, addr).await?; + + let (n, _) = tokio::time::timeout( + Duration::from_millis(config.native_auth_timeout_ms.max(1)), + socket.recv_from(&mut buf), + ) + .await??; + let packet = protocol::decode(&buf[..n])?; + if packet.header.kind != PacketKind::NativeAuthOk { + if packet.header.kind == PacketKind::AttachReject { + let reject: AttachReject = protocol::from_body(&packet.body)?; + return Err(anyhow!("native auth rejected: {}", reject.reason)); + } + return Err(anyhow!("native auth received unexpected auth response")); + } + let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?; + let ok: NativeAuthOkBody = protocol::from_body(&plain)?; + let frame = Frame { + session: ok.ok.session.clone(), + output_seq: ok.ok.initial_seq, + bytes: ok.ok.snapshot.clone(), + snapshot: true, + closed: false, + }; + let cred = CachedCredential { + server: requested_server.to_string(), + session: ok.ok.session, + mode: ok.ok.mode, + udp_host: udp_host.to_string(), + udp_port, + client_id: ok.ok.client_id, + session_key: ok.ok.session_key, + session_key_id: ok.ok.session_key_id, + attach_ticket: ok.ok.attach_ticket, + attach_ticket_psk: ok.ok.attach_ticket_psk, + last_rendered_seq: ok.ok.initial_seq, + }; + Ok((frame, cred)) +} + +fn load_first_native_identity( + config: &dosh::config::ClientConfig, + cli_identity: Option<&Path>, + ssh_config: &SshConfig, +) -> Result { + let mut paths = Vec::new(); + if let Some(path) = cli_identity { + paths.push(path.to_path_buf()); + } + paths.extend( + ssh_config + .identity_files + .iter() + .map(|path| expand_tilde(path)), + ); + paths.extend(config.identity_files.iter().map(|path| expand_tilde(path))); + paths.sort(); + paths.dedup(); + + let mut errors = Vec::new(); + for path in paths { + if !path.exists() { + continue; + } + match load_ed25519_identity(&path) { + Ok(identity) => return Ok(identity), + Err(err) => errors.push(format!("{}: {err:#}", path.display())), + } + } + if errors.is_empty() { + Err(anyhow!("no usable ssh-ed25519 identity found")) + } else { + Err(anyhow!( + "no usable ssh-ed25519 identity found: {}", + errors.join("; ") + )) + } +} + async fn bootstrap_attach( socket: &UdpSocket, server_name: &str, @@ -1424,8 +1696,8 @@ const TERMINAL_CLEANUP: &[u8] = concat!( #[cfg(test)] mod tests { use super::{ - FrameBuffer, Predictor, parse_ssh_config, raw_contains_host_table, ssh_destination_host, - startup_command, toml_bare_key_or_quoted, + FrameBuffer, Predictor, auth_allows, parse_ssh_config, raw_contains_host_table, + ssh_destination_host, ssh_username, startup_command, toml_bare_key_or_quoted, }; use dosh::protocol::Frame; @@ -1454,6 +1726,18 @@ mod tests { assert_eq!(parsed.port, Some(2222)); } + #[test] + fn parses_ssh_config_user_and_identity_files() { + let parsed = parse_ssh_config( + "user palav\nidentityfile ~/.ssh/id_ed25519\nidentityfile ~/.ssh/work\n", + ); + assert_eq!(parsed.user, Some("palav".to_string())); + assert_eq!( + parsed.identity_files, + vec!["~/.ssh/id_ed25519".to_string(), "~/.ssh/work".to_string()] + ); + } + #[test] fn detects_existing_imported_host_tables() { assert!(raw_contains_host_table( @@ -1470,6 +1754,20 @@ mod tests { assert_eq!(ssh_destination_host("palav@example.com"), "example.com"); } + #[test] + fn parses_user_from_ssh_destination() { + assert_eq!(ssh_username("palav@example.com"), Some("palav".to_string())); + assert_eq!(ssh_username("example.com"), None); + } + + #[test] + fn auth_preference_accepts_auto_or_named_method() { + assert!(auth_allows("native,ssh", "native")); + assert!(auth_allows("native,ssh", "ssh")); + assert!(auth_allows("auto", "native")); + assert!(!auth_allows("native", "ssh")); + } + fn test_frame(seq: u64, snapshot: bool) -> Frame { Frame { session: "test".to_string(), diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 7c98675..00f438d 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -1,16 +1,21 @@ use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use dosh::auth::{ - build_bootstrap, encode_bootstrap, load_or_create_server_secret, open_attach_ticket, - verify_bootstrap, + build_attach_ticket, build_bootstrap, encode_bootstrap, load_or_create_server_secret, now_secs, + open_attach_ticket, verify_bootstrap, }; use dosh::config::{ServerConfig, load_server_config}; use dosh::crypto; -use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key}; +use dosh::native::{ + NativeAuthOk, NativeServerHello, derive_native_session_key, generate_native_ephemeral, + host_public_key, host_public_key_line, load_or_create_host_key, sign_server_hello, + verify_native_user_auth_from_config, +}; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, - PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, - TicketAttachEnvelope, TicketAttachOkEnvelope, + NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, + ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope, + TicketAttachOkEnvelope, }; use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session}; use std::collections::{HashMap, VecDeque}; @@ -157,6 +162,7 @@ struct ServerState { secret: [u8; 32], pty_tx: mpsc::UnboundedSender, sessions: HashMap, + pending_native: HashMap<[u8; 16], PendingNativeAuth>, } struct Session { @@ -190,6 +196,14 @@ struct PendingFrame { attempts: u8, } +struct PendingNativeAuth { + client: dosh::native::NativeClientHello, + server: NativeServerHello, + session_key: [u8; 32], + peer: SocketAddr, + created_at: Instant, +} + impl ServerState { fn new( config: ServerConfig, @@ -201,6 +215,7 @@ impl ServerState { secret, pty_tx, sessions: HashMap::new(), + pending_native: HashMap::new(), } } @@ -237,6 +252,10 @@ async fn handle_packet( ) -> Result<()> { let packet = protocol::decode(raw)?; match packet.header.kind { + PacketKind::NativeClientHello => { + handle_native_client_hello(state, socket, peer, packet.body).await + } + PacketKind::NativeUserAuth => handle_native_user_auth(state, socket, peer, &packet).await, PacketKind::BootstrapAttachRequest => { handle_bootstrap_attach(state, socket, peer, packet.body).await } @@ -258,6 +277,246 @@ async fn handle_packet( } } +async fn handle_native_client_hello( + state: &Arc>, + socket: &Arc, + peer: SocketAddr, + body: Vec, +) -> Result<()> { + let req: NativeClientHelloBody = protocol::from_body(&body)?; + let built: Result<([u8; 16], NativeServerHello)> = { + let mut locked = state.lock().expect("server state poisoned"); + if !locked.config.native_auth { + Err(anyhow!("native auth disabled")) + } else if !req + .hello + .supported_aead + .iter() + .any(|algorithm| algorithm == "chacha20poly1305") + { + Err(anyhow!("native auth requires chacha20poly1305")) + } else if !req + .hello + .supported_user_key_algorithms + .iter() + .any(|algorithm| algorithm == "ssh-ed25519") + { + Err(anyhow!("native auth requires ssh-ed25519")) + } else { + let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); + if req.hello.requested_user != current_user { + Err(anyhow!("native auth user mismatch")) + } else { + let host_signing = load_or_create_host_key(&locked.config)?; + let (server_secret, server_public) = generate_native_ephemeral(); + let mut hello = NativeServerHello { + protocol_version: dosh::native::NATIVE_PROTOCOL_VERSION, + server_random: crypto::random_32(), + server_ephemeral_public: server_public, + host_key: host_public_key(&host_signing), + chosen_aead: "chacha20poly1305".to_string(), + server_key_epoch: 1, + auth_challenge: crypto::random_32(), + rate_limit_remaining: Some(locked.config.native_auth_rate_limit_per_minute), + host_signature: Vec::new(), + }; + sign_server_hello(&host_signing, &req.hello, &mut hello)?; + let session_key = derive_native_session_key( + &server_secret, + req.hello.client_ephemeral_public, + &req.hello, + &hello, + )?; + let mut pending_id = [0u8; 16]; + pending_id.copy_from_slice(&hello.auth_challenge[..16]); + locked.pending_native.insert( + pending_id, + PendingNativeAuth { + client: req.hello, + server: hello.clone(), + session_key, + peer, + created_at: Instant::now(), + }, + ); + Ok((pending_id, hello)) + } + } + }; + let (pending_id, hello) = match built { + Ok(built) => built, + Err(err) => return send_reject(socket, peer, &err.to_string()).await, + }; + let body = protocol::to_body(&NativeServerHelloBody { hello })?; + let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?; + socket.send_to(&out, peer).await?; + Ok(()) +} + +async fn handle_native_user_auth( + state: &Arc>, + socket: &Arc, + peer: SocketAddr, + packet: &protocol::Packet, +) -> Result<()> { + let pending = { + let mut locked = state.lock().expect("server state poisoned"); + locked.pending_native.remove(&packet.header.conn_id) + }; + let Some(pending) = pending else { + return send_reject_to_client(socket, peer, packet.header.conn_id, "unknown native auth") + .await; + }; + if pending.peer != peer { + return send_reject_to_client( + socket, + peer, + packet.header.conn_id, + "native auth peer changed", + ) + .await; + } + if pending.created_at.elapsed() > Duration::from_secs(30) { + return send_reject_to_client(socket, peer, packet.header.conn_id, "native auth expired") + .await; + } + let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?; + let req: NativeUserAuthBody = protocol::from_body(&body)?; + + let attached: Result<( + [u8; 16], + [u8; 16], + Vec, + [u8; 32], + String, + String, + u64, + Vec, + )> = { + let mut locked = state.lock().expect("server state poisoned"); + verify_native_user_auth_from_config( + &locked.config, + &pending.client, + &pending.server, + &req.auth, + ) + .context("verify native user auth") + .and_then(|_| { + let session_name = pending.client.requested_session.clone(); + let mode = pending.client.requested_mode.clone(); + let cols = pending.client.terminal_size.0; + let rows = pending.client.terminal_size.1; + if !locked.sessions.contains_key(&session_name) { + if locked.config.create_on_attach { + locked.ensure_session(&session_name, cols, rows)?; + } else { + return Err(anyhow!("session does not exist")); + } + } + let session_key = pending.session_key; + let key_id = { + let digest = crypto::sha256(&session_key); + let mut out = [0u8; 16]; + out.copy_from_slice(&digest[..16]); + out + }; + let attach_ticket_psk = crypto::random_32(); + let issued_at = now_secs()?; + let attach_ticket = build_attach_ticket( + &locked.secret, + crypto::sha256(&locked.secret), + 1, + pending.client.requested_user.clone(), + session_name.clone(), + mode.clone(), + issued_at, + issued_at + locked.config.attach_ticket_ttl_secs, + &attach_ticket_psk, + )?; + let session = locked + .sessions + .get_mut(&session_name) + .expect("session exists"); + if mode != "view-only" { + session.pty.resize(cols, rows)?; + session.parser.set_size(rows, cols); + } + let client_id = crypto::random_16(); + let snapshot = screen_snapshot(session.parser.screen()); + let screen = session.parser.screen().clone(); + let output_seq = session.output_seq; + session.clients.insert( + client_id, + ClientState { + endpoint: peer, + mode: mode.clone(), + session_key, + last_acked: output_seq, + replay: ReplayWindow::default(), + send_seq: 1, + cols, + rows, + last_seen: Instant::now(), + pending: VecDeque::new(), + last_screen: Some(screen), + }, + ); + Ok(( + client_id, + key_id, + attach_ticket, + attach_ticket_psk, + session_name, + mode, + output_seq, + snapshot, + )) + }) + }; + + let ( + client_id, + key_id, + attach_ticket, + attach_ticket_psk, + session_name, + mode, + output_seq, + snapshot, + ) = match attached { + Ok(attached) => attached, + Err(err) => { + return send_reject_to_client(socket, peer, packet.header.conn_id, &err.to_string()) + .await; + } + }; + + let ok = NativeAuthOk { + client_id, + session: session_name, + mode, + session_key: pending.session_key, + session_key_id: key_id, + attach_ticket, + attach_ticket_psk, + initial_seq: output_seq, + snapshot, + policy_flags: Vec::new(), + }; + let body = protocol::to_body(&NativeAuthOkBody { ok })?; + let out = protocol::encode_encrypted( + PacketKind::NativeAuthOk, + client_id, + 1, + packet.header.seq, + &pending.session_key, + SERVER_TO_CLIENT, + &body, + )?; + socket.send_to(&out, peer).await?; + Ok(()) +} + async fn handle_bootstrap_attach( state: &Arc>, socket: &Arc, diff --git a/src/native.rs b/src/native.rs index 85a5757..ea4ed04 100644 --- a/src/native.rs +++ b/src/native.rs @@ -10,6 +10,7 @@ use std::fs; use std::io::Write; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519"; pub const NATIVE_PROTOCOL_VERSION: u8 = 1; @@ -87,9 +88,10 @@ pub struct NativeAuthOk { pub client_id: [u8; 16], pub session: String, pub mode: String, + pub session_key: [u8; 32], pub session_key_id: [u8; 16], pub attach_ticket: Vec, - pub encrypted_attach_ticket_psk: Vec, + pub attach_ticket_psk: [u8; 32], pub initial_seq: u64, pub snapshot: Vec, pub policy_flags: Vec, @@ -331,6 +333,46 @@ pub fn verify_native_user_auth_from_config( verify_native_user_auth(client, server, auth, &authorized_keys) } +pub fn generate_native_ephemeral() -> (StaticSecret, [u8; 32]) { + let secret = StaticSecret::random(); + let public = X25519PublicKey::from(&secret).to_bytes(); + (secret, public) +} + +pub fn derive_native_session_key( + local_secret: &StaticSecret, + remote_public: [u8; 32], + client: &NativeClientHello, + server: &NativeServerHello, +) -> Result<[u8; 32]> { + let remote_public = X25519PublicKey::from(remote_public); + let shared = local_secret.diffie_hellman(&remote_public); + anyhow::ensure!( + shared.was_contributory(), + "native key exchange produced non-contributory shared secret" + ); + let mut salt = b"dosh/native/session-key/v1".to_vec(); + salt.extend(crypto::sha256(&bincode::serialize(client)?)); + salt.extend(crypto::sha256(&bincode::serialize(server)?)); + crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1") +} + +pub fn load_ed25519_identity(path: &Path) -> Result { + let private_key = ssh_key::PrivateKey::read_openssh_file(path) + .with_context(|| format!("read OpenSSH identity {}", path.display()))?; + anyhow::ensure!( + !private_key.is_encrypted(), + "OpenSSH identity {} is encrypted; encrypted-key prompts are not wired yet", + path.display() + ); + let ed25519 = private_key + .key_data() + .ed25519() + .ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is not ssh-ed25519", path.display()))?; + ed25519_dalek::SigningKey::try_from(ed25519) + .with_context(|| format!("parse ssh-ed25519 identity {}", path.display())) +} + impl AuthorizedKey { fn ensure_native_allowed(&self, auth: &NativeUserAuth) -> Result<()> { if !self.options.unsupported.is_empty() { @@ -864,6 +906,64 @@ mod tests { assert!(verify_native_user_auth(&client, &server, &auth, &authorized).is_err()); } + #[test] + fn load_ed25519_identity_reads_openssh_private_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("id_ed25519"); + let signing = SigningKey::from_bytes(&[11u8; 32]); + let keypair = ssh_key::private::Ed25519Keypair::from(&signing); + let private = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "").unwrap(); + private + .write_openssh_file(&path, ssh_key::LineEnding::LF) + .unwrap(); + + let loaded = load_ed25519_identity(&path).unwrap(); + assert_eq!( + VerifyingKey::from(&loaded).to_bytes(), + VerifyingKey::from(&signing).to_bytes() + ); + } + + #[test] + fn native_session_key_derivation_matches_and_binds_transcript() { + let host_signing = SigningKey::from_bytes(&[3u8; 32]); + let (client_secret, client_public) = generate_native_ephemeral(); + let (server_secret, server_public) = generate_native_ephemeral(); + let mut client = test_client_hello(); + client.client_ephemeral_public = client_public; + let mut server = test_server_hello(&host_signing); + server.server_ephemeral_public = server_public; + sign_server_hello(&host_signing, &client, &mut server).unwrap(); + + let client_key = derive_native_session_key( + &client_secret, + server.server_ephemeral_public, + &client, + &server, + ) + .unwrap(); + let server_key = derive_native_session_key( + &server_secret, + client.client_ephemeral_public, + &client, + &server, + ) + .unwrap(); + assert_eq!(client_key, server_key); + + let mut tampered = server.clone(); + tampered.auth_challenge = [99u8; 32]; + let tampered_key = derive_native_session_key( + &client_secret, + tampered.server_ephemeral_public, + &client, + &tampered, + ) + .unwrap(); + assert_ne!(client_key, tampered_key); + } + #[test] fn native_user_auth_enforces_no_port_forwarding_and_permitopen() { let host_signing = SigningKey::from_bytes(&[3u8; 32]); diff --git a/src/protocol.rs b/src/protocol.rs index 3d0822d..a65c2da 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,5 +1,6 @@ use crate::auth::BootstrapResponse; use crate::crypto; +use crate::native::{NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; @@ -25,6 +26,10 @@ pub enum PacketKind { Ping = 11, Pong = 12, Detach = 13, + NativeClientHello = 14, + NativeServerHello = 15, + NativeUserAuth = 16, + NativeAuthOk = 17, } impl TryFrom for PacketKind { @@ -45,6 +50,10 @@ impl TryFrom for PacketKind { 11 => Self::Ping, 12 => Self::Pong, 13 => Self::Detach, + 14 => Self::NativeClientHello, + 15 => Self::NativeServerHello, + 16 => Self::NativeUserAuth, + 17 => Self::NativeAuthOk, _ => bail!("unknown packet kind {value}"), }) } @@ -214,6 +223,26 @@ pub struct TicketAttachOkEnvelope { pub ciphertext: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeClientHelloBody { + pub hello: NativeClientHello, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeServerHelloBody { + pub hello: NativeServerHello, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeUserAuthBody { + pub auth: NativeUserAuth, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeAuthOkBody { + pub ok: NativeAuthOk, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttachOk { pub client_id: [u8; 16], diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index 35630d9..c2141ac 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -7,6 +7,7 @@ use std::time::Duration; use dosh::auth::{build_bootstrap, load_or_create_server_secret}; use dosh::config::load_server_config; use dosh::crypto; +use dosh::native::{host_public_key, load_or_create_host_key, trust_host}; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, @@ -47,9 +48,13 @@ create_on_attach = true shell = "/bin/sh" sessions_dir = "{sessions}" secret_path = "{secret}" +host_key = "{host_key}" +authorized_keys = ["{authorized_keys}"] "#, sessions = dir.path().join("sessions").display(), secret = dir.path().join("secret").display(), + host_key = dir.path().join("host_key").display(), + authorized_keys = dir.path().join("authorized_keys").display(), ), ) .unwrap(); @@ -89,6 +94,54 @@ fn attach_once(dir: &tempfile::TempDir, port: u16, cache: bool) -> std::process: cmd.output().unwrap() } +fn native_attach_once(dir: &tempfile::TempDir, port: u16) -> std::process::Output { + let client = env!("CARGO_BIN_EXE_dosh-client"); + Command::new(client) + .arg("--auth") + .arg("native") + .arg("--attach-only") + .arg("-v") + .arg("--session") + .arg("default") + .arg("--dosh-host") + .arg("127.0.0.1") + .arg("--dosh-port") + .arg(port.to_string()) + .arg("local") + .env("HOME", dir.path()) + .output() + .unwrap() +} + +fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) { + let ssh_dir = dir.path().join(".ssh"); + fs::create_dir_all(&ssh_dir).unwrap(); + let signing = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]); + let keypair = ssh_key::private::Ed25519Keypair::from(&signing); + let private = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap(); + private + .write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF) + .unwrap(); + fs::write( + dir.path().join("authorized_keys"), + format!("{}\n", private.public_key().to_openssh().unwrap()), + ) + .unwrap(); + + let config = load_server_config(Some(config_path.to_path_buf())).unwrap(); + let host_signing = load_or_create_host_key(&config).unwrap(); + let known_hosts = dir.path().join(".config/dosh/known_hosts"); + trust_host( + &known_hosts, + "local", + &host_public_key(&host_signing), + "test", + false, + ) + .unwrap(); +} + fn direct_attach( config: &std::path::Path, port: u16, @@ -275,6 +328,22 @@ fn local_attach_only_smoke() { ); } +#[test] +fn native_attach_only_smoke() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + write_native_client_auth(&dir, &config); + let mut server = start_server(&dir, &config); + let output = native_attach_once(&dir, port); + let _ = server.kill(); + let _ = server.wait(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "stderr={stderr}"); + assert!(stderr.contains("native_auth"), "stderr={stderr}"); + assert!(stderr.contains("terminal_ready"), "stderr={stderr}"); +} + #[test] fn ticket_attach_after_server_restart_smoke() { let dir = tempfile::tempdir().unwrap();