Implement the headless SSH server
This commit is contained in:
317
crates/bds-server/src/auth.rs
Normal file
317
crates/bds-server/src/auth.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use russh::keys::key::safe_rng;
|
||||
use russh::keys::{Algorithm, PrivateKey, PublicKey, load_secret_key, ssh_key};
|
||||
|
||||
pub const HOST_KEY_FILE: &str = "ssh_host_rsa_key";
|
||||
pub const AUTHORIZED_KEYS_FILE: &str = "authorized_keys";
|
||||
pub const CLIENT_KEY_FILE: &str = "id_ed25519";
|
||||
pub const KNOWN_HOSTS_FILE: &str = "known_hosts";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyMaterial {
|
||||
pub directory: PathBuf,
|
||||
pub host_key_path: PathBuf,
|
||||
pub authorized_keys_path: PathBuf,
|
||||
}
|
||||
|
||||
impl KeyMaterial {
|
||||
pub fn ensure(data_dir: &Path) -> Result<Self> {
|
||||
let directory = data_dir.join("ssh");
|
||||
ensure_private_directory(&directory)?;
|
||||
let host_key_path = directory.join(HOST_KEY_FILE);
|
||||
let authorized_keys_path = directory.join(AUTHORIZED_KEYS_FILE);
|
||||
ensure_private_file(&authorized_keys_path, b"")?;
|
||||
if !host_key_path.exists() {
|
||||
let key = PrivateKey::random(&mut safe_rng(), Algorithm::Rsa { hash: None })
|
||||
.context("could not generate the SSH host key")?;
|
||||
let encoded = key
|
||||
.to_openssh(ssh_key::LineEnding::LF)
|
||||
.context("could not encode the SSH host key")?;
|
||||
ensure_private_file(&host_key_path, encoded.as_bytes())?;
|
||||
}
|
||||
validate_private_file(&host_key_path)?;
|
||||
validate_private_file(&authorized_keys_path)?;
|
||||
load_secret_key(&host_key_path, None)
|
||||
.with_context(|| format!("could not read SSH host key {}", host_key_path.display()))?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
host_key_path,
|
||||
authorized_keys_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn host_key(&self) -> Result<PrivateKey> {
|
||||
validate_private_file(&self.host_key_path)?;
|
||||
load_secret_key(&self.host_key_path, None).with_context(|| {
|
||||
format!(
|
||||
"could not read SSH host key {}",
|
||||
self.host_key_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-reads the file for every authentication attempt, so removing a key
|
||||
/// revokes it without restarting the server.
|
||||
pub fn authorizes(&self, candidate: &PublicKey) -> Result<bool> {
|
||||
validate_private_file(&self.authorized_keys_path)?;
|
||||
let contents = fs::read_to_string(&self.authorized_keys_path).with_context(|| {
|
||||
format!(
|
||||
"could not read authorized keys {}",
|
||||
self.authorized_keys_path.display()
|
||||
)
|
||||
})?;
|
||||
for (index, line) in contents.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields = line.split_whitespace().collect::<Vec<_>>();
|
||||
let key_start = fields
|
||||
.iter()
|
||||
.position(|field| {
|
||||
field.starts_with("ssh-")
|
||||
|| field.starts_with("ecdsa-")
|
||||
|| field.starts_with("sk-")
|
||||
})
|
||||
.filter(|index| fields.get(index + 1).is_some());
|
||||
let encoded = key_start
|
||||
.map(|index| format!("{} {}", fields[index], fields[index + 1]))
|
||||
.unwrap_or_else(|| line.to_owned());
|
||||
let key = PublicKey::from_openssh(&encoded).with_context(|| {
|
||||
format!(
|
||||
"invalid authorized key at {}:{}",
|
||||
self.authorized_keys_path.display(),
|
||||
index + 1
|
||||
)
|
||||
})?;
|
||||
if key.key_data() == candidate.key_data() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientKeyMaterial {
|
||||
pub directory: PathBuf,
|
||||
pub private_key_path: PathBuf,
|
||||
pub public_key_path: PathBuf,
|
||||
pub known_hosts_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ClientKeyMaterial {
|
||||
pub fn ensure(data_dir: &Path) -> Result<Self> {
|
||||
let directory = data_dir.join("ssh");
|
||||
ensure_private_directory(&directory)?;
|
||||
let private_key_path = directory.join(CLIENT_KEY_FILE);
|
||||
let public_key_path = directory.join(format!("{CLIENT_KEY_FILE}.pub"));
|
||||
let known_hosts_path = directory.join(KNOWN_HOSTS_FILE);
|
||||
ensure_private_file(&known_hosts_path, b"")?;
|
||||
if !private_key_path.exists() {
|
||||
let mut key = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519)
|
||||
.context("could not generate the SSH client identity")?;
|
||||
key.set_comment("ruds-desktop");
|
||||
let private = key
|
||||
.to_openssh(ssh_key::LineEnding::LF)
|
||||
.context("could not encode the SSH client identity")?;
|
||||
ensure_private_file(&private_key_path, private.as_bytes())?;
|
||||
let public = format!("{}\n", key.public_key().to_openssh()?);
|
||||
ensure_public_file(&public_key_path, public.as_bytes())?;
|
||||
}
|
||||
validate_private_file(&private_key_path)?;
|
||||
validate_private_file(&known_hosts_path)?;
|
||||
let key = load_secret_key(&private_key_path, None).with_context(|| {
|
||||
format!(
|
||||
"could not read SSH client identity {}",
|
||||
private_key_path.display()
|
||||
)
|
||||
})?;
|
||||
let public = format!("{}\n", key.public_key().to_openssh()?);
|
||||
ensure_public_file(&public_key_path, public.as_bytes())?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
private_key_path,
|
||||
public_key_path,
|
||||
known_hosts_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_private_directory(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(path).with_context(|| {
|
||||
format!("could not create private SSH directory {}", path.display())
|
||||
})?;
|
||||
set_mode(path, 0o700)?;
|
||||
}
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
bail!("SSH key directory is not a directory: {}", path.display());
|
||||
}
|
||||
validate_mode(path, 0o700, "SSH key directory")
|
||||
}
|
||||
|
||||
fn ensure_private_file(path: &Path, contents: &[u8]) -> Result<()> {
|
||||
if path.exists() {
|
||||
return validate_private_file(path);
|
||||
}
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(path)
|
||||
.with_context(|| format!("could not create private SSH file {}", path.display()))?;
|
||||
file.write_all(contents)?;
|
||||
file.sync_all()?;
|
||||
set_mode(path, 0o600)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_public_file(path: &Path, contents: &[u8]) -> Result<()> {
|
||||
if path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
file.write_all(contents)?;
|
||||
file.sync_all()?;
|
||||
set_mode(path, 0o644)
|
||||
}
|
||||
|
||||
fn validate_private_file(path: &Path) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
bail!(
|
||||
"SSH key file is missing or not a regular file: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
validate_mode(path, 0o600, "SSH key file")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_mode(path: &Path, maximum: u32, label: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = fs::metadata(path)?.permissions().mode() & 0o777;
|
||||
if mode & !maximum != 0 {
|
||||
bail!(
|
||||
"unsafe permissions {mode:o} on {label} {}; expected {maximum:o}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn validate_mode(_path: &Path, _maximum: u32, _label: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_mode(_path: &Path, _mode: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn creates_and_reuses_restrictive_server_key_material() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = KeyMaterial::ensure(root.path()).unwrap();
|
||||
let bytes = fs::read(&first.host_key_path).unwrap();
|
||||
assert_eq!(
|
||||
first.host_key().unwrap().algorithm(),
|
||||
Algorithm::Rsa { hash: None }
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&first.authorized_keys_path).unwrap(), "");
|
||||
let second = KeyMaterial::ensure(root.path()).unwrap();
|
||||
assert_eq!(fs::read(second.host_key_path).unwrap(), bytes);
|
||||
assert_private(&first.directory, 0o700);
|
||||
assert_private(&first.host_key_path, 0o600);
|
||||
assert_private(&first.authorized_keys_path, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorized_keys_accept_reject_and_revoke_immediately() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let material = KeyMaterial::ensure(root.path()).unwrap();
|
||||
let allowed = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap();
|
||||
let unknown = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap();
|
||||
fs::write(
|
||||
&material.authorized_keys_path,
|
||||
format!(
|
||||
"# desktop\nrestrict {} user@desktop\n",
|
||||
allowed.public_key().to_openssh().unwrap()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(material.authorizes(allowed.public_key()).unwrap());
|
||||
assert!(!material.authorizes(unknown.public_key()).unwrap());
|
||||
fs::write(&material.authorized_keys_path, "").unwrap();
|
||||
assert!(!material.authorizes(allowed.public_key()).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unsafe_key_files_are_rejected_with_the_path_and_mode() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let material = KeyMaterial::ensure(root.path()).unwrap();
|
||||
fs::set_permissions(
|
||||
&material.authorized_keys_path,
|
||||
fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.unwrap();
|
||||
let error = material
|
||||
.authorizes(
|
||||
PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519)
|
||||
.unwrap()
|
||||
.public_key(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unsafe permissions 644"));
|
||||
assert!(error.contains(AUTHORIZED_KEYS_FILE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_a_reusable_desktop_identity_and_known_hosts() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = ClientKeyMaterial::ensure(root.path()).unwrap();
|
||||
let bytes = fs::read(&first.private_key_path).unwrap();
|
||||
fs::remove_file(&first.public_key_path).unwrap();
|
||||
let second = ClientKeyMaterial::ensure(root.path()).unwrap();
|
||||
assert_eq!(fs::read(second.private_key_path).unwrap(), bytes);
|
||||
assert!(first.public_key_path.is_file());
|
||||
assert_private(&first.private_key_path, 0o600);
|
||||
assert_private(&first.known_hosts_path, 0o600);
|
||||
}
|
||||
|
||||
fn assert_private(path: &Path, expected: u32) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
assert_eq!(
|
||||
fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user