diff --git a/crates/rshc/src/cmd/serve_ssh.rs b/crates/rshc/src/cmd/serve_ssh.rs index 3c72cb4..e6f3b9e 100644 --- a/crates/rshc/src/cmd/serve_ssh.rs +++ b/crates/rshc/src/cmd/serve_ssh.rs @@ -45,8 +45,9 @@ pub async fn run( } }; - // Load the operator's SSH key once. It serves as both the SSH host key and, - // via its public half, an always-allowed client key. + // Load the operator's SSH key once. Its public half is folded into the + // client allowlist (an always-allowed client key). It is NOT used as the + // SSH host key — see load_or_create_host_key. let operator_key = load_operator_key(&cfg)?; // Build the set of client public keys allowed to connect: the backend's @@ -70,13 +71,8 @@ pub async fn run( ..Default::default() }; - // Derive the SSH host key from the operator key, round-tripped through - // OpenSSH encoding (russh uses a different ssh-key version than we do). - let host_pem = operator_key - .to_openssh(ssh_key::LineEnding::LF) - .context("encode host key")?; - let host_key = russh::keys::PrivateKey::from_openssh(host_pem.as_bytes()) - .map_err(|e| anyhow!("parse host key: {e}"))?; + // Dedicated, persistent SSH host key (never the operator's auth key). + let host_key = load_or_create_host_key()?; config.keys.push(host_key); let config = Arc::new(config); @@ -129,6 +125,53 @@ fn load_operator_key(cfg: &Config) -> Result { Ok(key) } +/// Load the persistent serve-ssh host key, generating an Ed25519 one on first +/// use. This is a dedicated key, distinct from the operator's auth key, so the +/// listener has a stable identity without cross-context key reuse. +fn load_or_create_host_key() -> Result { + let path = Config::serve_ssh_host_key_path(); + let key: ssh_key::PrivateKey = if path.exists() { + let raw = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + ssh_key::PrivateKey::from_openssh(&raw).context("parse host key")? + } else { + let k = ssh_key::PrivateKey::random(&mut rand::rngs::OsRng, ssh_key::Algorithm::Ed25519) + .map_err(|e| anyhow!("generate host key: {e}"))?; + let pem = k.to_openssh(ssh_key::LineEnding::LF).context("encode host key")?; + write_private(&path, pem.as_bytes())?; + ui::print_info(&format!("generated serve-ssh host key at {}", path.display())); + k + }; + ui::print_info(&format!( + "host key fingerprint {}", + key.fingerprint(ssh_key::HashAlg::Sha256) + )); + // Round-trip into russh's ssh-key version (russh re-exports an incompatible + // ssh-key release, so we cannot hand it our value directly). + let pem = key.to_openssh(ssh_key::LineEnding::LF).context("encode host key")?; + russh::keys::PrivateKey::from_openssh(pem.as_bytes()).map_err(|e| anyhow!("parse host key: {e}")) +} + +/// Write private-key bytes to `path` with `0600` permissions, creating the +/// parent directory if needed. +fn write_private(path: &std::path::Path, bytes: &[u8]) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("create {}", path.display()))?; + f.write_all(bytes) + .with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + /// SHA-256 fingerprints of every public key allowed to connect: the backend's /// authorized_keys plus the operator's own key. async fn build_allowed_fingerprints( diff --git a/crates/rshc/src/config.rs b/crates/rshc/src/config.rs index 096ccc6..43ed37b 100644 --- a/crates/rshc/src/config.rs +++ b/crates/rshc/src/config.rs @@ -43,4 +43,14 @@ impl Config { pub fn ssh_key_path(&self) -> PathBuf { PathBuf::from(shellexpand::tilde(&self.ssh_key_file).to_string()) } + + /// Path to the dedicated, persistent serve-ssh host key, kept next to the + /// config file (respecting `RSHC_CONFIG_PATH`). + pub fn serve_ssh_host_key_path() -> PathBuf { + Self::path() + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_default() + .join("serve_ssh_host_ed25519") + } }