//! Per-user SSH keys for agents, terminals and the git panel. //! //! The keys live in the database and are rendered into the user's home (`.ssh/`), like the git //! credentials. OpenSSH takes `~` from the passwd entry, not from `$HOME`, so plain `ssh` would //! not look into the sandbox home. A small `ssh`/`scp`/`sftp` wrapper directory goes first in PATH //! and applies the managed config with `-F`; `GIT_SSH_COMMAND` points at the wrapper. Two //! variants: one for the sandbox (paths through `${HOME}`) and one for unsandboxed runs (admins; //! absolute host paths). use crate::app::App; use crate::store::User; use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tokio::process::Command; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SshKey { pub id: String, pub name: String, pub public: String, pub fingerprint: String, /// OpenSSH private key (never sent to the UI). #[serde(default)] pub private: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SshSettings { #[serde(default)] pub keys: Vec, } /// Keys without the private part, for the UI. pub fn masked(s: &SshSettings) -> Vec { s.keys.iter().map(|k| SshKey { private: String::new(), ..k.clone() }).collect() } fn ssh_dir(home: &Path) -> PathBuf { home.join(".ssh") } /// Wrappers for unsandboxed runs. fn bin_dir(home: &Path) -> PathBuf { home.join(".codecraft-bin") } /// Wrappers inside the sandbox (see `sandbox::bwrap_args`). pub const SANDBOX_BIN: &str = ".codecraft-sbin"; fn key_file(id: &str) -> String { format!("codecraft_{id}") } /// SSH client config. `sandbox`: paths through `${HOME}` (the home is mounted at a different /// place there); otherwise absolute paths below `home` (wrapper for unsandboxed runs). pub fn render_config(keys: &[SshKey], home: &Path, sandbox: bool) -> String { let mut c = String::from("# Managed by CodeCraft (Settings → Account → SSH keys). Do not edit.\nHost *\n"); for k in keys { if sandbox { c.push_str(&format!("\tIdentityFile ${{HOME}}/.ssh/{}\n", key_file(&k.id))); } else { c.push_str(&format!("\tIdentityFile {}\n", ssh_dir(home).join(key_file(&k.id)).display())); } } // Trust a host on first contact and refuse changed keys afterwards. c.push_str("\tStrictHostKeyChecking accept-new\n\tServerAliveInterval 30\n"); if sandbox { c.push_str("\tUserKnownHostsFile ${HOME}/.ssh/known_hosts\n"); } else { c.push_str(&format!("\tUserKnownHostsFile {}\n", ssh_dir(home).join("known_hosts").display())); } c } #[cfg(unix)] fn chmod(p: &Path, mode: u32) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?; Ok(()) } #[cfg(not(unix))] fn chmod(_: &Path, _: u32) -> Result<()> { Ok(()) } /// Write keys, config and wrappers into the user's home (removes files of deleted keys). pub fn write_files(app: &App, user: &User, s: &SshSettings) -> Result<()> { let home = crate::gitcreds::home_of(app, user); let dir = ssh_dir(&home); std::fs::create_dir_all(&dir)?; chmod(&dir, 0o700)?; // Old key files. for e in std::fs::read_dir(&dir)?.flatten() { let n = e.file_name().to_string_lossy().into_owned(); if n.starts_with("codecraft_") && !s.keys.iter().any(|k| n == key_file(&k.id) || n == format!("{}.pub", key_file(&k.id))) { let _ = std::fs::remove_file(e.path()); } } if s.keys.is_empty() { // No keys: no wrappers either, ssh behaves as usual. let _ = std::fs::remove_dir_all(bin_dir(&home)); let _ = std::fs::remove_dir_all(home.join(SANDBOX_BIN)); for f in ["config-sandbox", "config-host"] { let _ = std::fs::remove_file(dir.join(f)); } return Ok(()); } for k in &s.keys { let p = dir.join(key_file(&k.id)); // The file is replaced, not appended: remove it first so the mode applies. let _ = std::fs::remove_file(&p); std::fs::write(&p, &k.private)?; chmod(&p, 0o600)?; let pubp = dir.join(format!("{}.pub", key_file(&k.id))); std::fs::write(&pubp, format!("{}\n", k.public.trim()))?; chmod(&pubp, 0o644)?; } // The user's own ~/.ssh/config (if any) is included after the managed part. let own = dir.join("config"); let sandbox_cfg = dir.join("config-sandbox"); std::fs::write(&sandbox_cfg, format!("{}\nInclude /home/{}/.ssh/config\n", render_config(&s.keys, &home, true), user.username))?; chmod(&sandbox_cfg, 0o600)?; let host_cfg = dir.join("config-host"); std::fs::write(&host_cfg, format!("{}\nInclude {}\n", render_config(&s.keys, &home, false), own.display()))?; chmod(&host_cfg, 0o600)?; for (bin, config) in [(bin_dir(&home), format!("'{}'", host_cfg.display())), (home.join(SANDBOX_BIN), "\"$HOME/.ssh/config-sandbox\"".to_string())] { std::fs::create_dir_all(&bin)?; for name in ["ssh", "scp", "sftp"] { // The image's /usr/local/bin wrappers cope with a uid without passwd entry. let local = format!("/usr/local/bin/{name}"); let real = if Path::new(&local).exists() { local } else { format!("/usr/bin/{name}") }; let p = bin.join(name); std::fs::write(&p, format!("#!/bin/sh\nexec {real} -F {config} \"$@\"\n"))?; chmod(&p, 0o755)?; } } Ok(()) } /// Environment for processes that run without the sandbox: the wrappers first in PATH, git over /// SSH through the wrapper. Empty when the user has no keys. pub fn env(app: &App, user: &User) -> Vec<(String, String)> { let s: SshSettings = app.store.user_setting(&user.id, "ssh").unwrap_or_default(); if s.keys.is_empty() { return Vec::new(); } let bin = bin_dir(&crate::gitcreds::home_of(app, user)); if !bin.join("ssh").exists() { return Vec::new(); } let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into())); vec![("PATH".into(), path), ("GIT_SSH_COMMAND".into(), bin.join("ssh").display().to_string())] } /// What an agent needs to know (public keys only). None without keys. pub fn prompt_section(app: &App, user_id: &str) -> Option { render_prompt(&app.store.user_setting(user_id, "ssh").unwrap_or_default()) } fn render_prompt(s: &SshSettings) -> Option { if s.keys.is_empty() { return None; } let names: Vec<&str> = s.keys.iter().map(|k| k.name.as_str()).collect(); Some(format!( "\n# SSH access\nThe user has SSH keys set up ({}). `ssh`, `scp`, `sftp`, `rsync -e ssh` and `git` over ssh:// or git@host use them automatically – \ no password, no `ssh-add`, no agent. A host is trusted on first contact. Do NOT generate new keys or search for keys or passwords; \ if a server refuses the connection, tell the user that their public key must be added to the server's authorized_keys. \ Commands run without a terminal, so password or passphrase prompts fail.\n", names.join(", ") )) } // --- creating keys --------------------------------------------------------------------- async fn keygen(args: &[&str], stdin: Option<&str>) -> Result { let mut cmd = Command::new("ssh-keygen"); cmd.args(args).env_clear().env("PATH", std::env::var("PATH").unwrap_or_default()).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()); let mut child = cmd.spawn().context("ssh-keygen is not installed on the server")?; if let Some(mut si) = child.stdin.take() { use tokio::io::AsyncWriteExt; if let Some(t) = stdin { let _ = si.write_all(t.as_bytes()).await; } } let out = tokio::time::timeout(std::time::Duration::from_secs(20), child.wait_with_output()).await.map_err(|_| anyhow!("ssh-keygen timed out"))??; if !out.status.success() { bail!("{}", String::from_utf8_lossy(&out.stderr).trim()); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } fn temp_dir() -> Result { let d = std::env::temp_dir().join(format!("cc-ssh-{}", crate::types::new_id())); std::fs::create_dir_all(&d)?; chmod(&d, 0o700)?; Ok(d) } /// A new ed25519 key without passphrase. pub async fn generate(name: &str) -> Result { let d = temp_dir()?; let f = d.join("key"); let comment = format!("codecraft-{}", name.replace(char::is_whitespace, "-")); let res = async { keygen(&["-q", "-t", "ed25519", "-N", "", "-C", &comment, "-f", &f.to_string_lossy()], None).await?; let private = std::fs::read_to_string(&f)?; let public = std::fs::read_to_string(d.join("key.pub"))?; finish(name, private, public.trim().to_string()).await } .await; let _ = std::fs::remove_dir_all(&d); res } /// An existing private key (OpenSSH or PEM, without passphrase). pub async fn import(name: &str, private: &str) -> Result { let mut text = private.trim().replace("\r\n", "\n"); if !text.starts_with("-----BEGIN ") || !text.contains("PRIVATE KEY-----") { bail!("that does not look like a private key (-----BEGIN … PRIVATE KEY-----)"); } text.push('\n'); let d = temp_dir()?; let f = d.join("key"); let res = async { std::fs::write(&f, &text)?; chmod(&f, 0o600)?; let public = keygen(&["-y", "-P", "", "-f", &f.to_string_lossy()], None).await.map_err(|e| anyhow!("cannot read the key (keys with a passphrase are not supported): {e}"))?; finish(name, text.clone(), public.trim().to_string()).await } .await; let _ = std::fs::remove_dir_all(&d); res } async fn finish(name: &str, private: String, public: String) -> Result { let fp = keygen(&["-l", "-f", "-"], Some(&format!("{public}\n"))).await.unwrap_or_default(); let fingerprint = fp.split_whitespace().nth(1).unwrap_or("").to_string(); Ok(SshKey { id: crate::types::new_id().chars().take(10).collect(), name: name.to_string(), public, fingerprint, private }) } #[cfg(test)] mod tests { use super::*; fn key(id: &str) -> SshKey { SshKey { id: id.into(), name: "laptop".into(), public: "ssh-ed25519 AAAA x".into(), fingerprint: "SHA256:x".into(), private: "SECRET".into() } } #[test] fn config_has_both_path_styles() { let home = Path::new("/data/users/ann/home"); let t = render_config(&[key("k1")], home, true); assert!(t.contains("IdentityFile ${HOME}/.ssh/codecraft_k1") && t.contains("UserKnownHostsFile ${HOME}/.ssh/known_hosts")); assert!(t.contains("StrictHostKeyChecking accept-new")); let a = render_config(&[key("k1")], home, false); assert!(a.contains("IdentityFile /data/users/ann/home/.ssh/codecraft_k1")); assert!(a.contains("UserKnownHostsFile /data/users/ann/home/.ssh/known_hosts")); } #[test] fn sandbox_config_resolves_through_home() { if std::process::Command::new("ssh").arg("-V").output().is_err() { return; } let d = std::env::temp_dir().join(format!("cc-sshcfg-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); let cfg = d.join("cfg"); std::fs::write(&cfg, render_config(&[key("k1")], Path::new("/unused"), true)).unwrap(); let out = std::process::Command::new("ssh").arg("-F").arg(&cfg).args(["-G", "example.org"]).env("HOME", "/sandbox/home").output().unwrap(); let text = String::from_utf8_lossy(&out.stdout).to_lowercase(); // `-G` shows identity files unexpanded (expanded when used, ssh_config(5)). assert!(text.contains("identityfile ${home}/.ssh/codecraft_k1"), "{text}"); assert!(text.contains("userknownhostsfile /sandbox/home/.ssh/known_hosts"), "{text}"); std::fs::remove_dir_all(&d).unwrap(); } #[test] fn prompt_and_ui_view_never_contain_the_private_key() { let s = SshSettings { keys: vec![key("k1")] }; assert!(masked(&s).iter().all(|k| k.private.is_empty())); let p = render_prompt(&s).unwrap(); assert!(p.contains("laptop") && !p.contains("SECRET")); assert!(render_prompt(&SshSettings::default()).is_none()); } #[tokio::test] async fn generated_and_imported_keys_match() { if std::process::Command::new("ssh-keygen").arg("-?").output().is_err() { return; // not installed here } let k = generate("test key").await.unwrap(); assert!(k.public.starts_with("ssh-ed25519 ") && k.fingerprint.starts_with("SHA256:") && k.private.contains("PRIVATE KEY")); let again = import("copy", &k.private).await.unwrap(); assert_eq!(again.fingerprint, k.fingerprint); assert!(import("bad", "hello").await.is_err()); } }