use anyhow::{Context, Result, bail};
use russh::keys::PrivateKey;
use russh::keys::ssh_key::{Algorithm, LineEnding};
use crate::shell::Dialect;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizedKeysFile {
pub dir: String,
pub file: String,
pub administrators: bool,
}
const WINDOWS_ADMIN_DIR: &str = "C:\\ProgramData\\ssh";
impl AuthorizedKeysFile {
pub fn locate(dialect: Dialect, home: &str, administrator: bool) -> Self {
let home = home.trim_end_matches(['/', '\\']);
match dialect {
Dialect::Posix => Self {
dir: format!("{home}/.ssh"),
file: format!("{home}/.ssh/authorized_keys"),
administrators: false,
},
Dialect::Windows if administrator => Self {
dir: WINDOWS_ADMIN_DIR.to_string(),
file: format!("{WINDOWS_ADMIN_DIR}\\administrators_authorized_keys"),
administrators: true,
},
Dialect::Windows => Self {
dir: format!("{home}\\.ssh"),
file: format!("{home}\\.ssh\\authorized_keys"),
administrators: false,
},
}
}
}
pub const WINDOWS_ADMIN_PROBE: &str = "net session >nul 2>&1";
pub fn reads_as_administrator(probe: Result<String>) -> bool {
probe.is_ok()
}
pub fn already_authorized(existing: &str, line: &str) -> bool {
let Some(wanted) = key_body(line) else {
return false;
};
existing.lines().filter_map(key_body).any(|body| body == wanted)
}
fn key_body(line: &str) -> Option<(String, String)> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
line.split_whitespace()
.zip(line.split_whitespace().skip(1))
.find(|(kind, _)| kind.starts_with("ssh-") || kind.starts_with("ecdsa-") || kind.starts_with("sk-"))
.map(|(kind, body)| (kind.to_string(), body.to_string()))
}
pub struct GeneratedKey {
pub private: String,
pub public: String,
}
pub fn generate(comment: &str) -> Result<GeneratedKey> {
let mut key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).context("cannot generate an ed25519 key")?;
key.set_comment(comment);
let private = key.to_openssh(LineEnding::LF).context("cannot serialize the generated key")?.to_string();
let public = key.public_key().to_openssh().context("cannot serialize the generated public key")?;
Ok(GeneratedKey { private, public })
}
pub fn public_line(key_path: &str, passphrase: Option<&str>, fallback_comment: &str) -> Result<String> {
let pub_path = format!("{key_path}.pub");
if let Ok(text) = std::fs::read_to_string(&pub_path) {
let line = text.trim();
if !line.is_empty() {
return Ok(line.to_string());
}
}
let key = russh::keys::load_secret_key(key_path, passphrase).with_context(|| format!("cannot read the key file {key_path}"))?;
let mut public = key.public_key().to_openssh().context("cannot derive the public key")?;
if key_body(&public).is_some() && public.split_whitespace().count() < 3 {
public.push(' ');
public.push_str(fallback_comment);
}
Ok(public)
}
pub fn check_line(dialect: Dialect, line: &str) -> Result<()> {
if let Err(reason) = dialect.reject_unquotable(line) {
bail!("this public key cannot be installed on a cmd.exe server: {reason}");
}
if line.lines().count() != 1 {
bail!("a public key must be a single line");
}
Ok(())
}
pub fn default_key_path(credential: &str) -> Result<std::path::PathBuf> {
let home = directories::UserDirs::new().ok_or_else(|| anyhow::anyhow!("cannot locate the home directory"))?;
Ok(home.home_dir().join(".ssh").join(format!("id_ed25519_{credential}")))
}
pub fn write_private_key(path: &std::path::Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", parent.display()))?;
}
if path.exists() {
bail!("{} already exists - remove it or point the credential at it instead", path.display());
}
std::fs::write(path, contents).with_context(|| format!("cannot write {}", path.display()))?;
restrict_key_file(path)?;
Ok(())
}
#[cfg(unix)]
fn restrict_key_file(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).with_context(|| format!("cannot restrict {}", path.display()))
}
#[cfg(not(unix))]
fn restrict_key_file(_path: &std::path::Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_windows_administrator_gets_the_programdata_file() {
let placed = AuthorizedKeysFile::locate(Dialect::Windows, "C:\\Users\\deploy", true);
assert!(placed.administrators);
assert_eq!(placed.dir, "C:\\ProgramData\\ssh");
assert_eq!(placed.file, "C:\\ProgramData\\ssh\\administrators_authorized_keys");
}
#[test]
fn an_ordinary_windows_account_gets_its_own_file() {
let placed = AuthorizedKeysFile::locate(Dialect::Windows, "C:\\Users\\deploy", false);
assert!(!placed.administrators);
assert_eq!(placed.file, "C:\\Users\\deploy\\.ssh\\authorized_keys");
}
#[test]
fn posix_ignores_the_administrator_question() {
let user = AuthorizedKeysFile::locate(Dialect::Posix, "/home/pi", false);
let root = AuthorizedKeysFile::locate(Dialect::Posix, "/root", true);
assert_eq!(user.file, "/home/pi/.ssh/authorized_keys");
assert_eq!(root.file, "/root/.ssh/authorized_keys");
assert!(!root.administrators);
}
#[test]
fn a_trailing_separator_does_not_double() {
assert_eq!(
AuthorizedKeysFile::locate(Dialect::Posix, "/home/pi/", false).file,
"/home/pi/.ssh/authorized_keys"
);
assert_eq!(
AuthorizedKeysFile::locate(Dialect::Windows, "C:\\Users\\deploy\\", false).file,
"C:\\Users\\deploy\\.ssh\\authorized_keys"
);
}
#[test]
fn an_installed_key_is_recognized_whatever_its_comment() {
let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample turnout@desktop";
let existing = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample someone-elses-comment\n";
assert!(already_authorized(existing, line));
}
#[test]
fn a_key_behind_options_still_counts_as_installed() {
let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample turnout@desktop";
let existing = "from=\"10.0.0.0/8\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample locked-down\n";
assert!(already_authorized(existing, line));
}
#[test]
fn a_different_key_is_not_installed() {
let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample turnout@desktop";
let existing = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDifferent other\n# a comment\n\n";
assert!(!already_authorized(existing, line));
assert!(!already_authorized("", line));
}
#[test]
fn comments_and_blank_lines_hold_no_keys() {
assert!(!already_authorized("# nothing here\n\n \n", "ssh-ed25519 AAAAB body"));
}
#[test]
fn generates_a_usable_ed25519_pair() {
let key = generate("turnout@test").expect("generate a key");
assert!(key.private.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----"));
assert!(key.public.starts_with("ssh-ed25519 "));
assert!(key.public.ends_with(" turnout@test"), "{}", key.public);
assert_eq!(key.public.lines().count(), 1);
let scratch = tempfile::tempdir().expect("a scratch directory");
let path = scratch.path().join("id_ed25519");
write_private_key(&path, &key.private).expect("write the key");
let loaded = russh::keys::load_secret_key(path.display().to_string(), None).expect("the written key loads back");
assert_eq!(loaded.public_key().to_openssh().expect("serialize"), key.public);
}
#[test]
fn every_generated_key_is_new() {
let first = generate("turnout@test").expect("generate");
let second = generate("turnout@test").expect("generate");
assert_ne!(first.public, second.public);
}
#[test]
fn an_existing_key_file_is_never_overwritten() {
let scratch = tempfile::tempdir().expect("a scratch directory");
let path = scratch.path().join("id_ed25519");
std::fs::write(&path, "precious").expect("seed the file");
let error = write_private_key(&path, "new key").expect_err("must refuse").to_string();
assert!(error.contains("already exists"), "{error}");
assert_eq!(std::fs::read_to_string(&path).expect("read back"), "precious");
}
#[test]
fn the_public_half_comes_from_the_pub_file_when_there_is_one() {
let scratch = tempfile::tempdir().expect("a scratch directory");
let path = scratch.path().join("id_ed25519");
let key = generate("original@comment").expect("generate");
write_private_key(&path, &key.private).expect("write the key");
std::fs::write(path.with_extension("").with_file_name("id_ed25519.pub"), "ssh-ed25519 AAAAB chosen@comment\n").expect("write the pub");
let line = public_line(&path.display().to_string(), None, "turnout@here").expect("read the public half");
assert_eq!(line, "ssh-ed25519 AAAAB chosen@comment");
}
#[test]
fn the_public_half_is_derived_when_there_is_no_pub_file() {
let scratch = tempfile::tempdir().expect("a scratch directory");
let path = scratch.path().join("id_ed25519");
let key = generate("").expect("generate");
write_private_key(&path, &key.private).expect("write the key");
let line = public_line(&path.display().to_string(), None, "turnout@here").expect("derive the public half");
assert!(line.starts_with("ssh-ed25519 "), "{line}");
assert!(line.ends_with(" turnout@here"), "{line}");
}
#[test]
fn a_line_cmd_cannot_carry_is_refused() {
let good = "ssh-ed25519 AAAAB turnout@desktop";
assert!(check_line(Dialect::Windows, good).is_ok());
assert!(check_line(Dialect::Posix, good).is_ok());
assert!(check_line(Dialect::Windows, "ssh-ed25519 AAAAB say \"hi\"").is_err());
assert!(check_line(Dialect::Windows, "ssh-ed25519 AAAAB 100%done").is_err());
assert!(check_line(Dialect::Posix, "ssh-ed25519 AAAAB one\nssh-ed25519 AAAAB two").is_err());
}
#[test]
fn a_refused_probe_means_not_an_administrator() {
assert!(reads_as_administrator(Ok(String::new())));
assert!(!reads_as_administrator(Err(anyhow::anyhow!("exited with 1"))));
}
}