SSH keys in account settings; wrappers for agents, terminal and git

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-15 11:47:31 +01:00
1 parent 247ddcf14a
commit 6e8e739906
16 files changed
+517 -6

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
//! 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, real) in [("ssh", "/usr/bin/ssh"), ("scp", "/usr/bin/scp"), ("sftp", "/usr/bin/sftp")] {
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());
}
}