Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
300 lines
13 KiB
Rust
300 lines
13 KiB
Rust
//! 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<SshKey>,
|
||
}
|
||
|
||
/// Keys without the private part, for the UI.
|
||
pub fn masked(s: &SshSettings) -> Vec<SshKey> {
|
||
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<String> {
|
||
render_prompt(&app.store.user_setting(user_id, "ssh").unwrap_or_default())
|
||
}
|
||
|
||
fn render_prompt(s: &SshSettings) -> Option<String> {
|
||
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<String> {
|
||
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<PathBuf> {
|
||
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<SshKey> {
|
||
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<SshKey> {
|
||
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<SshKey> {
|
||
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());
|
||
}
|
||
}
|