diff --git a/Cargo.lock b/Cargo.lock index 409e9a7..4613e3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -209,6 +215,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -254,6 +266,43 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -298,6 +347,7 @@ dependencies = [ "clap", "crossterm", "dirs", + "ed25519-dalek", "hkdf", "hmac", "portable-pty", @@ -316,6 +366,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -338,6 +412,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -630,6 +710,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -755,6 +845,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -945,6 +1044,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -961,6 +1069,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 7e902c1..90ced13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ chacha20poly1305 = "0.10" clap = { version = "4.5", features = ["derive"] } crossterm = "0.28" dirs = "5.0" +ed25519-dalek = "2.1" hkdf = "0.12" hmac = "0.12" portable-pty = "0.8" diff --git a/install.sh b/install.sh index 5e99b6b..8ae7630 100755 --- a/install.sh +++ b/install.sh @@ -214,6 +214,13 @@ create_on_attach = true shell = "$login_shell" sessions_dir = "~/.local/share/dosh/sessions" secret_path = "~/.config/dosh/secret" +native_auth = true +host_key = "~/.config/dosh/host_key" +authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"] +native_auth_rate_limit_per_minute = 30 +allow_tcp_forwarding = true +allow_remote_forwarding = false +allow_agent_forwarding = false EOF fi @@ -268,6 +275,13 @@ view_only = false predict = false cache_attach_tickets = true credential_cache = "~/.local/share/dosh/credentials" +auth_preference = "native,ssh" +trust_on_first_use = false +native_auth_timeout_ms = 700 +known_hosts = "~/.config/dosh/known_hosts" +identity_files = ["~/.ssh/id_ed25519"] +use_ssh_agent = true +forward_agent = false EOF fi diff --git a/src/bin/dosh-auth.rs b/src/bin/dosh-auth.rs index 3ae66b7..0692e10 100644 --- a/src/bin/dosh-auth.rs +++ b/src/bin/dosh-auth.rs @@ -2,13 +2,16 @@ use anyhow::{Context, Result}; use clap::Parser; use dosh::auth::{build_bootstrap, encode_bootstrap, load_or_create_server_secret}; use dosh::config::load_server_config; +use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key}; #[derive(Debug, Parser)] struct Args { #[arg(long, default_value_t = 1)] protocol: u8, #[arg(long)] - nonce: String, + nonce: Option, + #[arg(long)] + host_key: bool, #[arg(long, default_value = "default")] session: String, #[arg(long, default_value = "read-write")] @@ -25,8 +28,13 @@ fn main() -> Result<()> { let args = Args::parse(); anyhow::ensure!(args.protocol == 1, "unsupported protocol {}", args.protocol); let config = load_server_config(None)?; + if args.host_key { + let signing_key = load_or_create_host_key(&config)?; + println!("{}", host_public_key_line(&host_public_key(&signing_key))); + return Ok(()); + } let secret = load_or_create_server_secret(&config)?; - let nonce = parse_nonce(&args.nonce)?; + let nonce = parse_nonce(args.nonce.as_deref().context("--nonce is required")?)?; let size = parse_size(&args.size)?; let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); let udp_host = args.udp_host.unwrap_or_else(|| "127.0.0.1".to_string()); diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index a054e62..fe14e16 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -8,6 +8,9 @@ use dosh::auth::{ }; use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_server_config}; use dosh::crypto; +use dosh::native::{ + TrustResult, host_fingerprint, parse_host_public_key_line, remove_trusted_host, trust_host, +}; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope, @@ -42,6 +45,10 @@ struct Args { #[arg(long)] no_cache: bool, #[arg(long)] + remove: bool, + #[arg(long)] + replace: bool, + #[arg(long)] attach_only: bool, #[arg(long)] predict: bool, @@ -88,6 +95,9 @@ async fn main() -> Result<()> { if args.server.as_deref() == Some("import-ssh") { return run_import_ssh(&config, &args.command); } + if args.server.as_deref() == Some("trust") { + return run_trust_command(&config, &args); + } let requested_server = args.server.unwrap_or_else(|| config.server.clone()); let hosts = load_hosts_config(None).unwrap_or_default(); let host = hosts @@ -278,6 +288,96 @@ async fn main() -> Result<()> { .await } +fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { + let host = args + .command + .first() + .ok_or_else(|| anyhow!("usage: dosh trust [--remove] [--replace] "))?; + let path = expand_tilde(&config.known_hosts); + if args.remove { + if remove_trusted_host(&path, host)? { + eprintln!("dosh trust: removed {host} from {}", path.display()); + } else { + eprintln!("dosh trust: {host} was not trusted in {}", path.display()); + } + return Ok(()); + } + + let hosts = load_hosts_config(None).unwrap_or_default(); + let host_config = hosts.hosts.get(host).cloned().unwrap_or_default(); + let server = host_config.ssh.clone().unwrap_or_else(|| host.to_string()); + let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); + let ssh_auth_command = args + .ssh_auth_command + .clone() + .or_else(|| config.ssh_auth_command.clone()) + .unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string()); + let public_key = fetch_host_key_over_ssh( + &server, + ssh_port, + &ssh_auth_command, + args.ssh_key.as_deref(), + args.ssh_known_hosts.as_deref(), + args.ssh_control_path.as_deref(), + )?; + match trust_host(&path, host, &public_key, "ssh", args.replace)? { + TrustResult::AlreadyTrusted => { + eprintln!( + "dosh trust: {host} already trusted ({})", + host_fingerprint(&public_key) + ); + } + TrustResult::Trusted => { + eprintln!( + "dosh trust: trusted {host} {} in {}", + host_fingerprint(&public_key), + path.display() + ); + } + } + Ok(()) +} + +fn fetch_host_key_over_ssh( + server: &str, + ssh_port: Option, + ssh_auth_command: &str, + ssh_key: Option<&Path>, + ssh_known_hosts: Option<&Path>, + ssh_control_path: Option<&Path>, +) -> Result { + let mut command = Command::new("ssh"); + if let Some(ssh_port) = ssh_port { + command.arg("-p").arg(ssh_port.to_string()); + } + command.arg("-T"); + if let Some(key) = ssh_key { + command.arg("-i").arg(key); + } + if let Some(known_hosts) = ssh_known_hosts { + command + .arg("-o") + .arg(format!("UserKnownHostsFile={}", known_hosts.display())); + } + if let Some(control_path) = ssh_control_path { + command.arg("-S").arg(control_path); + } + let output = command + .arg(server) + .arg(ssh_auth_command) + .arg("--host-key") + .output() + .context("fetch Dosh host key over SSH")?; + if !output.status.success() { + return Err(anyhow!( + "Dosh host-key fetch failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let raw = String::from_utf8(output.stdout)?; + parse_host_public_key_line(&raw) +} + fn startup_command(args: &[String]) -> Option> { if args.is_empty() { return None; diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 09f265d..7c98675 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -6,6 +6,7 @@ use dosh::auth::{ }; 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::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, @@ -36,7 +37,9 @@ enum Command { #[arg(long, default_value_t = 1)] protocol: u8, #[arg(long)] - nonce: String, + nonce: Option, + #[arg(long)] + host_key: bool, #[arg(long, default_value = "default")] session: String, #[arg(long, default_value = "read-write")] @@ -58,6 +61,7 @@ async fn main() -> Result<()> { Command::Auth { protocol, nonce, + host_key, session, mode, size, @@ -66,8 +70,13 @@ async fn main() -> Result<()> { } => { anyhow::ensure!(protocol == 1, "unsupported protocol {protocol}"); let config = load_server_config(None)?; + if host_key { + let signing_key = load_or_create_host_key(&config)?; + println!("{}", host_public_key_line(&host_public_key(&signing_key))); + return Ok(()); + } let secret = load_or_create_server_secret(&config)?; - let nonce = parse_nonce(&nonce)?; + let nonce = parse_nonce(nonce.as_deref().context("--nonce is required")?)?; let size = parse_size(&size)?; let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); let udp_host = udp_host.unwrap_or_else(|| "127.0.0.1".to_string()); diff --git a/src/config.rs b/src/config.rs index 67e1ea8..5430a6e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,20 @@ pub struct ServerConfig { pub shell: String, pub sessions_dir: String, pub secret_path: String, + #[serde(default = "default_true")] + pub native_auth: bool, + #[serde(default = "default_host_key")] + pub host_key: String, + #[serde(default = "default_authorized_keys")] + pub authorized_keys: Vec, + #[serde(default = "default_native_auth_rate_limit_per_minute")] + pub native_auth_rate_limit_per_minute: u32, + #[serde(default = "default_true")] + pub allow_tcp_forwarding: bool, + #[serde(default)] + pub allow_remote_forwarding: bool, + #[serde(default)] + pub allow_agent_forwarding: bool, } impl Default for ServerConfig { @@ -39,6 +53,13 @@ impl Default for ServerConfig { shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()), sessions_dir: "~/.local/share/dosh/sessions".to_string(), secret_path: "~/.config/dosh/secret".to_string(), + native_auth: true, + host_key: default_host_key(), + authorized_keys: default_authorized_keys(), + native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(), + allow_tcp_forwarding: true, + allow_remote_forwarding: false, + allow_agent_forwarding: false, } } } @@ -59,6 +80,20 @@ pub struct ClientConfig { pub predict: bool, pub cache_attach_tickets: bool, pub credential_cache: String, + #[serde(default = "default_auth_preference")] + pub auth_preference: String, + #[serde(default)] + pub trust_on_first_use: bool, + #[serde(default = "default_native_auth_timeout_ms")] + pub native_auth_timeout_ms: u64, + #[serde(default = "default_known_hosts")] + pub known_hosts: String, + #[serde(default = "default_identity_files")] + pub identity_files: Vec, + #[serde(default = "default_true")] + pub use_ssh_agent: bool, + #[serde(default)] + pub forward_agent: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -93,10 +128,52 @@ impl Default for ClientConfig { predict: false, cache_attach_tickets: true, credential_cache: "~/.local/share/dosh/credentials".to_string(), + auth_preference: default_auth_preference(), + trust_on_first_use: false, + native_auth_timeout_ms: default_native_auth_timeout_ms(), + known_hosts: default_known_hosts(), + identity_files: default_identity_files(), + use_ssh_agent: true, + forward_agent: false, } } } +fn default_true() -> bool { + true +} + +fn default_host_key() -> String { + "~/.config/dosh/host_key".to_string() +} + +fn default_authorized_keys() -> Vec { + vec![ + "~/.ssh/authorized_keys".to_string(), + "~/.config/dosh/authorized_keys".to_string(), + ] +} + +fn default_native_auth_rate_limit_per_minute() -> u32 { + 30 +} + +fn default_auth_preference() -> String { + "native,ssh".to_string() +} + +fn default_native_auth_timeout_ms() -> u64 { + 700 +} + +fn default_known_hosts() -> String { + "~/.config/dosh/known_hosts".to_string() +} + +fn default_identity_files() -> Vec { + vec!["~/.ssh/id_ed25519".to_string()] +} + pub fn load_server_config(path: Option) -> Result { let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml")); if !path.exists() { diff --git a/src/lib.rs b/src/lib.rs index 2da2555..7a8ab53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod config; pub mod crypto; +pub mod native; pub mod protocol; pub mod pty; diff --git a/src/native.rs b/src/native.rs new file mode 100644 index 0000000..9f39a93 --- /dev/null +++ b/src/native.rs @@ -0,0 +1,299 @@ +use crate::auth::now_secs; +use crate::config::{ServerConfig, expand_tilde}; +use crate::crypto; +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostPublicKey { + pub algorithm: String, + pub key: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KnownHost { + pub host: String, + pub algorithm: String, + pub key: [u8; 32], + pub first_seen: u64, + pub source: String, +} + +pub fn load_or_create_host_key(config: &ServerConfig) -> Result { + let path = expand_tilde(&config.host_key); + if path.exists() { + let raw = fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let decoded = if raw.len() == 32 { + raw + } else { + URL_SAFE_NO_PAD + .decode(String::from_utf8_lossy(&raw).trim()) + .context("decode Dosh host key")? + }; + anyhow::ensure!(decoded.len() == 32, "Dosh host key must be 32 bytes"); + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&decoded); + return Ok(SigningKey::from_bytes(&bytes)); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let bytes = crypto::random_32(); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&path) + .with_context(|| format!("create {}", path.display()))?; + file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?; + file.write_all(b"\n")?; + Ok(SigningKey::from_bytes(&bytes)) +} + +pub fn host_public_key(signing_key: &SigningKey) -> HostPublicKey { + let verifying = VerifyingKey::from(signing_key); + HostPublicKey { + algorithm: HOST_KEY_ALGORITHM.to_string(), + key: verifying.to_bytes(), + } +} + +pub fn host_public_key_line(key: &HostPublicKey) -> String { + format!( + "{} {} {}", + key.algorithm, + URL_SAFE_NO_PAD.encode(key.key), + host_fingerprint(key) + ) +} + +pub fn parse_host_public_key_line(raw: &str) -> Result { + let mut parts = raw.split_whitespace(); + let algorithm = parts.next().context("missing Dosh host key algorithm")?; + if algorithm != HOST_KEY_ALGORITHM { + bail!("unsupported Dosh host key algorithm {algorithm}"); + } + let key_raw = parts.next().context("missing Dosh host public key")?; + let decoded = URL_SAFE_NO_PAD + .decode(key_raw) + .context("decode Dosh host public key")?; + anyhow::ensure!(decoded.len() == 32, "Dosh host public key must be 32 bytes"); + let mut key = [0u8; 32]; + key.copy_from_slice(&decoded); + Ok(HostPublicKey { + algorithm: algorithm.to_string(), + key, + }) +} + +pub fn host_fingerprint(key: &HostPublicKey) -> String { + format!( + "SHA256:{}", + URL_SAFE_NO_PAD.encode(crypto::sha256(&key.key)) + ) +} + +pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String { + format!( + "{} {} {} first-seen={} source={}", + host, + key.algorithm, + URL_SAFE_NO_PAD.encode(key.key), + first_seen, + source + ) +} + +pub fn parse_known_hosts(raw: &str) -> Result> { + raw.lines() + .enumerate() + .filter_map(|(index, line)| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + None + } else { + Some(parse_known_host_line(index + 1, line)) + } + }) + .collect() +} + +fn parse_known_host_line(line_number: usize, line: &str) -> Result { + let mut parts = line.split_whitespace(); + let host = parts + .next() + .with_context(|| format!("known_hosts:{line_number}: missing host"))?; + let algorithm = parts + .next() + .with_context(|| format!("known_hosts:{line_number}: missing algorithm"))?; + if algorithm != HOST_KEY_ALGORITHM { + bail!("known_hosts:{line_number}: unsupported algorithm {algorithm}"); + } + let key_raw = parts + .next() + .with_context(|| format!("known_hosts:{line_number}: missing key"))?; + let decoded = URL_SAFE_NO_PAD + .decode(key_raw) + .with_context(|| format!("known_hosts:{line_number}: decode key"))?; + anyhow::ensure!( + decoded.len() == 32, + "known_hosts:{line_number}: host key must be 32 bytes" + ); + let mut key = [0u8; 32]; + key.copy_from_slice(&decoded); + let mut first_seen = 0; + let mut source = "unknown".to_string(); + for part in parts { + if let Some(value) = part.strip_prefix("first-seen=") { + first_seen = value.parse().unwrap_or(0); + } else if let Some(value) = part.strip_prefix("source=") { + source = value.to_string(); + } + } + Ok(KnownHost { + host: host.to_string(), + algorithm: algorithm.to_string(), + key, + first_seen, + source, + }) +} + +pub fn trust_host( + path: &Path, + host: &str, + key: &HostPublicKey, + source: &str, + replace: bool, +) -> Result { + let raw = if path.exists() { + fs::read_to_string(path).with_context(|| format!("read {}", path.display()))? + } else { + String::new() + }; + let mut entries = parse_known_hosts(&raw)?; + if let Some(existing) = entries.iter().find(|entry| entry.host == host) { + if existing.key == key.key && existing.algorithm == key.algorithm { + return Ok(TrustResult::AlreadyTrusted); + } + if !replace { + bail!( + "Dosh host key mismatch for {host}: existing {}, new {}", + host_fingerprint(&HostPublicKey { + algorithm: existing.algorithm.clone(), + key: existing.key, + }), + host_fingerprint(key) + ); + } + entries.retain(|entry| entry.host != host); + } + entries.push(KnownHost { + host: host.to_string(), + algorithm: key.algorithm.clone(), + key: key.key, + first_seen: now_secs()?, + source: source.to_string(), + }); + write_known_hosts(path, &entries) +} + +pub fn remove_trusted_host(path: &Path, host: &str) -> Result { + if !path.exists() { + return Ok(false); + } + let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let mut entries = parse_known_hosts(&raw)?; + let before = entries.len(); + entries.retain(|entry| entry.host != host); + if entries.len() == before { + return Ok(false); + } + write_known_host_entries(path, &entries)?; + Ok(true) +} + +fn write_known_hosts(path: &Path, entries: &[KnownHost]) -> Result { + write_known_host_entries(path, entries)?; + Ok(TrustResult::Trusted) +} + +fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let mut out = String::new(); + for entry in entries { + out.push_str(&known_host_line( + &entry.host, + &HostPublicKey { + algorithm: entry.algorithm.clone(), + key: entry.key, + }, + &entry.source, + entry.first_seen, + )); + out.push('\n'); + } + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path) + .with_context(|| format!("write {}", path.display()))?; + file.write_all(out.as_bytes()) + .with_context(|| format!("write {}", path.display())) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustResult { + AlreadyTrusted, + Trusted, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_public_key_line_round_trips() { + let signing = SigningKey::from_bytes(&[7u8; 32]); + let public = host_public_key(&signing); + let line = host_public_key_line(&public); + let parsed = parse_host_public_key_line(&line).unwrap(); + assert_eq!(parsed, public); + assert!(host_fingerprint(&public).starts_with("SHA256:")); + } + + #[test] + fn known_host_trust_rejects_mismatch_without_replace() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("known_hosts"); + let first = host_public_key(&SigningKey::from_bytes(&[1u8; 32])); + let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); + + assert_eq!( + trust_host(&path, "palav", &first, "ssh", false).unwrap(), + TrustResult::Trusted + ); + assert_eq!( + trust_host(&path, "palav", &first, "ssh", false).unwrap(), + TrustResult::AlreadyTrusted + ); + assert!(trust_host(&path, "palav", &second, "ssh", false).is_err()); + assert_eq!( + trust_host(&path, "palav", &second, "ssh", true).unwrap(), + TrustResult::Trusted + ); + } +}