From 6e8e739906aff87e65dc05e158fcd55034ee3ef6 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 15 Nov 2026 11:47:31 +0100 Subject: [PATCH] SSH keys in account settings; wrappers for agents, terminal and git Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 + server/src/api.rs | 53 +++++ server/src/claude_code.rs | 5 +- server/src/git.rs | 6 +- server/src/main.rs | 1 + server/src/prompts.rs | 3 + server/src/sandbox.rs | 9 +- server/src/sshkeys.rs | 296 ++++++++++++++++++++++++ server/src/terminal.rs | 3 +- server/src/tools/bash.rs | 1 + web/src/components/settings/Account.tsx | 2 + web/src/components/settings/SshKeys.tsx | 98 ++++++++ web/src/i18n/de.ts | 15 ++ web/src/i18n/en.ts | 15 ++ web/src/styles.css | 4 + web/src/whatsnew.ts | 4 + 16 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 server/src/sshkeys.rs create mode 100644 web/src/components/settings/SshKeys.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ccfb6..09ca93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **SSH keys** (Settings → Account): generate (ed25519) or import keys; agents, the terminal and the + git panel use them automatically for `ssh`, `scp`, `sftp`, `rsync` and git over ssh. Hosts are + trusted on first contact; the public key can be copied for the server's `authorized_keys`. + ## [v202611-00032] – 2026-11-15 ### Fixed diff --git a/server/src/api.rs b/server/src/api.rs index 1a71e3b..c923e5f 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -157,6 +157,8 @@ pub fn router(app: AppState) -> Router { .route("/images/{id}", delete(delete_image)) .route("/images/{id}/file", get(image_file)) .route("/account/git", get(get_git_settings).put(put_git_settings)) + .route("/account/ssh", get(get_ssh_keys).post(add_ssh_key)) + .route("/account/ssh/{id}", delete(delete_ssh_key)) .route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar)) @@ -2426,6 +2428,57 @@ async fn run_job(Extension(user): Extension, State(app): State, // --- git credentials --------------------------------------------------------------- +// --- SSH keys ----------------------------------------------------------------- + +async fn get_ssh_keys(Extension(user): Extension, State(app): State) -> ApiResult { + let s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?; + Ok(Json(json!(crate::sshkeys::masked(&s)))) +} + +#[derive(Deserialize)] +struct NewSshKey { + name: String, + /// An existing private key to import; a new key is generated when missing. + private: Option, +} + +async fn add_ssh_key(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + let name = b.name.trim().to_string(); + if name.is_empty() || name.chars().count() > 60 { + return Err(ApiError(StatusCode::BAD_REQUEST, "give the key a name (up to 60 characters)".into())); + } + let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?; + if s.keys.len() >= 10 { + return Err(ApiError(StatusCode::BAD_REQUEST, "at most 10 keys".into())); + } + let key = match b.private.as_deref().filter(|p| !p.trim().is_empty()) { + Some(p) => crate::sshkeys::import(&name, p).await, + None => crate::sshkeys::generate(&name).await, + } + .map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; + if s.keys.iter().any(|k| k.fingerprint == key.fingerprint && !key.fingerprint.is_empty()) { + return Err(ApiError(StatusCode::CONFLICT, "this key is already stored".into())); + } + s.keys.push(key); + crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?; + app.store.set_user_setting(&user.id, "ssh", &s)?; + app.audit(Some(&user), "ssh.key.add", &name, ""); + Ok(Json(json!(crate::sshkeys::masked(&s)))) +} + +async fn delete_ssh_key(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?; + let before = s.keys.len(); + s.keys.retain(|k| k.id != id); + if s.keys.len() == before { + return Err(not_found("key")); + } + crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?; + app.store.set_user_setting(&user.id, "ssh", &s)?; + app.audit(Some(&user), "ssh.key.delete", &id, ""); + Ok(Json(json!(crate::sshkeys::masked(&s)))) +} + async fn get_git_settings(Extension(user): Extension, State(app): State) -> ApiResult { let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?; Ok(Json(json!(crate::gitcreds::masked(&s)))) diff --git a/server/src/claude_code.rs b/server/src/claude_code.rs index 589a2d5..cd600e9 100644 --- a/server/src/claude_code.rs +++ b/server/src/claude_code.rs @@ -104,6 +104,7 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul c.env("GIT_CONFIG_GLOBAL", cfg); } c.envs(crate::gitcreds::identity_env(app, &user.id)); + c.envs(crate::sshkeys::env(app, &user)); } Ok(c) } @@ -365,8 +366,8 @@ pub async fn run(app: Arc, session_id: &str, cancel: CancellationToken) -> args.extend(["--resume".into(), r.clone()]); } let mut appended = session.instructions.trim().to_string(); - if let Some(git) = owner.as_deref().and_then(|u| crate::gitcreds::prompt_section(&app, u)) { - appended = format!("{appended}\n{git}").trim().to_string(); + for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() { + appended = format!("{appended}\n{extra}").trim().to_string(); } if !appended.is_empty() { args.extend(["--append-system-prompt".into(), appended]); diff --git a/server/src/git.rs b/server/src/git.rs index 4439d81..dc3087e 100644 --- a/server/src/git.rs +++ b/server/src/git.rs @@ -174,7 +174,8 @@ fn parse_status(raw: &str) -> Value { pub async fn handle(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { let spec = crate::sandbox::spec_for(&app, Some(&user.id)); let cfg = crate::gitcreds::global_config(&app, &user); - let env = crate::gitcreds::identity_env(&app, &user.id); + let mut env = crate::gitcreds::identity_env(&app, &user.id); + env.extend(crate::sshkeys::env(&app, &user)); SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await } @@ -401,7 +402,8 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str { pub async fn worktree(app: &std::sync::Arc, user: &User, session: &mut crate::types::Session, action: &str) -> Result { let spec = crate::sandbox::spec_for(app, Some(&user.id)); let cfg = crate::gitcreds::global_config(app, user); - let env = crate::gitcreds::identity_env(app, &user.id); + let mut env = crate::gitcreds::identity_env(app, &user.id); + env.extend(crate::sshkeys::env(app, user)); let s = session.clone(); let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?; if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) { diff --git a/server/src/main.rs b/server/src/main.rs index a1ffbfb..ce43eb9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -34,6 +34,7 @@ mod reverse; mod runner; mod sandbox; mod shells; +mod sshkeys; mod stats_export; mod store; mod telegram; diff --git a/server/src/prompts.rs b/server/src/prompts.rs index 12885dd..a33af60 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -301,6 +301,9 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: & if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) { s.push_str(&git); } + if let Some(ssh) = owner.and_then(|u| crate::sshkeys::prompt_section(app, &u.id)) { + s.push_str(&ssh); + } match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::())), Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"), diff --git a/server/src/sandbox.rs b/server/src/sandbox.rs index 5e11d40..7aa83eb 100644 --- a/server/src/sandbox.rs +++ b/server/src/sandbox.rs @@ -132,7 +132,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio if new_session { push(&[&"--new-session"]); } - let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"); + // SSH wrappers (user's keys); only when keys are set up. + let ssh_wrapper = spec.home.join(crate::sshkeys::SANDBOX_BIN).join("ssh"); + let ssh_bin = format!("{home}/{}", crate::sshkeys::SANDBOX_BIN); + let path = format!("{}{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin", if ssh_wrapper.exists() { format!("{ssh_bin}:") } else { String::new() }); let npm = format!("{home}/.npm-global"); let pipx = format!("{home}/.local/pipx"); let pipx_bin = format!("{home}/.local/bin"); @@ -152,6 +155,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio ] { push(&[&"--setenv", &k, &v]); } + if ssh_wrapper.exists() { + let cmd = format!("{ssh_bin}/ssh"); + push(&[&"--setenv", &"GIT_SSH_COMMAND", &cmd]); + } for (k, v) in &spec.env { push(&[&"--setenv", k, v]); } diff --git a/server/src/sshkeys.rs b/server/src/sshkeys.rs new file mode 100644 index 0000000..abb393c --- /dev/null +++ b/server/src/sshkeys.rs @@ -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, +} + +/// 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, 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 { + 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()); + } +} diff --git a/server/src/terminal.rs b/server/src/terminal.rs index 1b70a7d..e4e6acd 100644 --- a/server/src/terminal.rs +++ b/server/src/terminal.rs @@ -162,7 +162,8 @@ pub fn spawn(app: &Arc, user: &User, cwd: &Path, session_id: Option .current_dir(cwd) .env("TERM", "xterm-256color") .env("CODECRAFT", "1") - .envs(crate::gitcreds::identity_env(app, &user.id)); + .envs(crate::gitcreds::identity_env(app, &user.id)) + .envs(crate::sshkeys::env(app, user)); match crate::gitcreds::global_config(app, user) { Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg), None => c, diff --git a/server/src/tools/bash.rs b/server/src/tools/bash.rs index 44dec2c..3d01d27 100644 --- a/server/src/tools/bash.rs +++ b/server/src/tools/bash.rs @@ -155,6 +155,7 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result + diff --git a/web/src/components/settings/SshKeys.tsx b/web/src/components/settings/SshKeys.tsx new file mode 100644 index 0000000..517fe10 --- /dev/null +++ b/web/src/components/settings/SshKeys.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from "react"; +import { del, get, post } from "../../api"; +import { useT } from "../../i18n"; +import { confirmDialog, toast } from "../../dialogs"; +import { Icon } from "../Icon"; + +interface Key { + id: string; + name: string; + public: string; + fingerprint: string; +} + +/** SSH keys of the user: used by agents, the terminal and the git panel to reach other servers. */ +export function SshKeys() { + const t = useT(); + const [keys, setKeys] = useState(); + const [name, setName] = useState(""); + const [priv, setPriv] = useState(""); + const [importing, setImporting] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + get("/account/ssh") + .then(setKeys) + .catch((e) => toast(e.message)); + }, []); + if (!keys) return null; + + const add = async () => { + setBusy(true); + try { + setKeys(await post("/account/ssh", { name, private: importing ? priv : undefined })); + setName(""); + setPriv(""); + toast(t("ssh.added"), "success"); + } catch (e: any) { + toast(e.message); + } finally { + setBusy(false); + } + }; + + const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => toast(t("ssh.copied"), "success"), () => toast(t("ssh.copyFailed"))); + + return ( +
+

+ {t("ssh.title")} +

+
+ {t("ssh.hint")} +
+ {keys.length === 0 &&
{t("ssh.none")}
} + {keys.map((k) => ( +
+
+ {k.name} + {k.fingerprint} + + + +
+ e.currentTarget.select()} aria-label={t("ssh.public")} className="mono" /> +
+ ))} +
+ setName(e.target.value)} placeholder={t("ssh.name")} maxLength={60} aria-label={t("ssh.name")} style={{ flex: "1 1 160px" }} /> + + +
+ {importing && ( + <> +