diff --git a/crates/rshc/src/cmd/serve_ssh.rs b/crates/rshc/src/cmd/serve_ssh.rs index 5d719a6..3c72cb4 100644 --- a/crates/rshc/src/cmd/serve_ssh.rs +++ b/crates/rshc/src/cmd/serve_ssh.rs @@ -2,11 +2,11 @@ use crate::auth::AuthedClient; use crate::cmd::connection; use crate::config::Config; use crate::ui; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use rsh_types::{AttachIOFrame, OpReq, OpResp}; use russh::server::{Auth, Session}; use russh::ChannelId; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use bytes::Bytes; use tokio::net::TcpListener; @@ -45,18 +45,39 @@ 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. + let operator_key = load_operator_key(&cfg)?; + + // Build the set of client public keys allowed to connect: the backend's + // authorized_keys plus the operator's own key. Refuse to start otherwise — + // an empty allowlist would be an open, unauthenticated shell. + let allowed_fps = build_allowed_fingerprints(&client, &operator_key).await?; + if allowed_fps.is_empty() { + return Err(anyhow!( + "no authorized keys available; refusing to start an open SSH server" + )); + } + let allowed_fps = Arc::new(allowed_fps); + ui::print_info(&format!("{} authorized key(s) loaded", allowed_fps.len())); + let mut config = russh::server::Config { inactivity_timeout: Some(std::time::Duration::from_secs(3600)), auth_rejection_time: std::time::Duration::from_secs(1), auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)), + // Only public-key auth is accepted; don't advertise password/none. + methods: russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]), ..Default::default() }; - - // Use a static ephemeral key for the SSH server - let key_str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBxYVtR5Xn/kk9PM26c//B9JlvqBBv4EbkWr3jRI8CLfQAAAJjAfItSwHyL\nUgAAAAtzc2gtZWQyNTUxOQAAACBxYVtR5Xn/kk9PM26c//B9JlvqBBv4EbkWr3jRI8CLfQ\nAAAEA3iPbbQVsorPV8V36bsOZWFHyYzqAx4SIPBQsVWW4RdHFhW1Hlef+ST08zbpz/8H0m\nW+oEG/gRuRaveNEjwIt9AAAAD2V0eEBldHgtZGVza3RvcAECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----"; - let key = russh::keys::PrivateKey::from_openssh(key_str) - .map_err(|e| anyhow!("Failed to parse SSH host key: {}", e))?; - config.keys.push(key); + + // 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}"))?; + config.keys.push(host_key); let config = Arc::new(config); let bind_addr = format!("{}:{}", listen_host, listen_port); @@ -74,6 +95,7 @@ pub async fn run( session_name: session_name.clone(), connection_id: conn_id, shell: shell.clone(), + allowed_fps: allowed_fps.clone(), clients: HashMap::new(), pty_reqs: HashMap::new(), }; @@ -85,11 +107,71 @@ pub async fn run( } } +fn reject() -> Auth { + Auth::Reject { + proceed_with_methods: None, + partial_success: false, + } +} + +/// Load and (if needed) decrypt the operator's SSH private key. +fn load_operator_key(cfg: &Config) -> Result { + let key_path = cfg.ssh_key_path(); + let raw = std::fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?; + let mut key = ssh_key::PrivateKey::from_openssh(&raw).context("parse openssh private key")?; + if key.is_encrypted() { + let pw = inquire::Password::new(&format!("Passphrase for {}:", key_path.display())) + .without_confirmation() + .prompt() + .map_err(|e| anyhow!("password prompt: {e}"))?; + key = key.decrypt(pw.as_bytes()).context("decrypt private key")?; + } + Ok(key) +} + +/// 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( + client: &AuthedClient, + operator_key: &ssh_key::PrivateKey, +) -> Result> { + let mut set = HashSet::new(); + + // The operator's own key is always allowed, even if not in authorized_keys. + let op_pub = operator_key + .public_key() + .to_openssh() + .context("encode operator pubkey")?; + if let Ok(pk) = russh::keys::PublicKey::from_openssh(&op_pub) { + set.insert(pk.fingerprint(russh::keys::HashAlg::Sha256).to_string()); + } + + // The backend's authorized_keys — the same trust set that gates operator auth. + match client.req(OpReq::KeysList).await? { + OpResp::Keys(lines) => { + for line in lines { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Ok(pk) = russh::keys::PublicKey::from_openssh(line) { + set.insert(pk.fingerprint(russh::keys::HashAlg::Sha256).to_string()); + } + } + } + OpResp::Err(e) => return Err(anyhow!(e)), + other => return Err(anyhow!("unexpected: {other:?}")), + } + + Ok(set) +} + struct SessionHandler { cfg: Config, session_name: String, connection_id: u64, shell: Option, + allowed_fps: Arc>, clients: HashMap>, pty_reqs: HashMap, @@ -184,20 +266,35 @@ impl SessionHandler { impl russh::server::Handler for SessionHandler { type Error = anyhow::Error; + async fn channel_open_session( + &mut self, + _channel: russh::Channel, + reply: russh::server::ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + reply.accept().await; + Ok(()) + } + async fn auth_publickey( &mut self, _: &str, - _: &russh::keys::PublicKey, + key: &russh::keys::PublicKey, ) -> Result { - Ok(Auth::Accept) + let fp = key.fingerprint(russh::keys::HashAlg::Sha256).to_string(); + if self.allowed_fps.contains(&fp) { + Ok(Auth::Accept) + } else { + Ok(reject()) + } } async fn auth_password(&mut self, _: &str, _: &str) -> Result { - Ok(Auth::Accept) + Ok(reject()) } - + async fn auth_none(&mut self, _: &str) -> Result { - Ok(Auth::Accept) + Ok(reject()) } async fn pty_request( diff --git a/crates/rshc/src/main.rs b/crates/rshc/src/main.rs index 3e885af..47df59a 100644 --- a/crates/rshc/src/main.rs +++ b/crates/rshc/src/main.rs @@ -101,7 +101,7 @@ struct ShellArgs { #[derive(Args, Debug)] struct ServeSshArgs { session: String, - #[arg(short = 'h', long, default_value = "127.0.0.1")] + #[arg(short = 'H', long, default_value = "127.0.0.1")] listen_host: String, #[arg(short = 'p', long, default_value_t = 2222)] listen_port: u16,