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:
1 parent
247ddcf14a
commit
6e8e739906
16 files changed
+517
-6
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user