Add native Dosh host trust foundation
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 11:32:01 -04:00
parent f6ab4473bd
commit 26fc863149
9 changed files with 631 additions and 4 deletions
Generated
+118
View File
@@ -86,6 +86,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bincode"
version = "1.3.3"
@@ -209,6 +215,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -254,6 +266,43 @@ dependencies = [
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -298,6 +347,7 @@ dependencies = [
"clap",
"crossterm",
"dirs",
"ed25519-dalek",
"hkdf",
"hmac",
"portable-pty",
@@ -316,6 +366,30 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -338,6 +412,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -630,6 +710,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "poly1305"
version = "0.8.0"
@@ -755,6 +845,15 @@ dependencies = [
"thiserror",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -945,6 +1044,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -961,6 +1069,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "strsim"
version = "0.11.1"
+1
View File
@@ -13,6 +13,7 @@ chacha20poly1305 = "0.10"
clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28"
dirs = "5.0"
ed25519-dalek = "2.1"
hkdf = "0.12"
hmac = "0.12"
portable-pty = "0.8"
+14
View File
@@ -214,6 +214,13 @@ create_on_attach = true
shell = "$login_shell"
sessions_dir = "~/.local/share/dosh/sessions"
secret_path = "~/.config/dosh/secret"
native_auth = true
host_key = "~/.config/dosh/host_key"
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
native_auth_rate_limit_per_minute = 30
allow_tcp_forwarding = true
allow_remote_forwarding = false
allow_agent_forwarding = false
EOF
fi
@@ -268,6 +275,13 @@ view_only = false
predict = false
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh"
trust_on_first_use = false
native_auth_timeout_ms = 700
known_hosts = "~/.config/dosh/known_hosts"
identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true
forward_agent = false
EOF
fi
+10 -2
View File
@@ -2,13 +2,16 @@ use anyhow::{Context, Result};
use clap::Parser;
use dosh::auth::{build_bootstrap, encode_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config;
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
#[derive(Debug, Parser)]
struct Args {
#[arg(long, default_value_t = 1)]
protocol: u8,
#[arg(long)]
nonce: String,
nonce: Option<String>,
#[arg(long)]
host_key: bool,
#[arg(long, default_value = "default")]
session: String,
#[arg(long, default_value = "read-write")]
@@ -25,8 +28,13 @@ fn main() -> Result<()> {
let args = Args::parse();
anyhow::ensure!(args.protocol == 1, "unsupported protocol {}", args.protocol);
let config = load_server_config(None)?;
if args.host_key {
let signing_key = load_or_create_host_key(&config)?;
println!("{}", host_public_key_line(&host_public_key(&signing_key)));
return Ok(());
}
let secret = load_or_create_server_secret(&config)?;
let nonce = parse_nonce(&args.nonce)?;
let nonce = parse_nonce(args.nonce.as_deref().context("--nonce is required")?)?;
let size = parse_size(&args.size)?;
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let udp_host = args.udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
+100
View File
@@ -8,6 +8,9 @@ use dosh::auth::{
};
use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_server_config};
use dosh::crypto;
use dosh::native::{
TrustResult, host_fingerprint, parse_host_public_key_line, remove_trusted_host, trust_host,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
@@ -42,6 +45,10 @@ struct Args {
#[arg(long)]
no_cache: bool,
#[arg(long)]
remove: bool,
#[arg(long)]
replace: bool,
#[arg(long)]
attach_only: bool,
#[arg(long)]
predict: bool,
@@ -88,6 +95,9 @@ async fn main() -> Result<()> {
if args.server.as_deref() == Some("import-ssh") {
return run_import_ssh(&config, &args.command);
}
if args.server.as_deref() == Some("trust") {
return run_trust_command(&config, &args);
}
let requested_server = args.server.unwrap_or_else(|| config.server.clone());
let hosts = load_hosts_config(None).unwrap_or_default();
let host = hosts
@@ -278,6 +288,96 @@ async fn main() -> Result<()> {
.await
}
fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
let host = args
.command
.first()
.ok_or_else(|| anyhow!("usage: dosh trust [--remove] [--replace] <host>"))?;
let path = expand_tilde(&config.known_hosts);
if args.remove {
if remove_trusted_host(&path, host)? {
eprintln!("dosh trust: removed {host} from {}", path.display());
} else {
eprintln!("dosh trust: {host} was not trusted in {}", path.display());
}
return Ok(());
}
let hosts = load_hosts_config(None).unwrap_or_default();
let host_config = hosts.hosts.get(host).cloned().unwrap_or_default();
let server = host_config.ssh.clone().unwrap_or_else(|| host.to_string());
let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port);
let ssh_auth_command = args
.ssh_auth_command
.clone()
.or_else(|| config.ssh_auth_command.clone())
.unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string());
let public_key = fetch_host_key_over_ssh(
&server,
ssh_port,
&ssh_auth_command,
args.ssh_key.as_deref(),
args.ssh_known_hosts.as_deref(),
args.ssh_control_path.as_deref(),
)?;
match trust_host(&path, host, &public_key, "ssh", args.replace)? {
TrustResult::AlreadyTrusted => {
eprintln!(
"dosh trust: {host} already trusted ({})",
host_fingerprint(&public_key)
);
}
TrustResult::Trusted => {
eprintln!(
"dosh trust: trusted {host} {} in {}",
host_fingerprint(&public_key),
path.display()
);
}
}
Ok(())
}
fn fetch_host_key_over_ssh(
server: &str,
ssh_port: Option<u16>,
ssh_auth_command: &str,
ssh_key: Option<&Path>,
ssh_known_hosts: Option<&Path>,
ssh_control_path: Option<&Path>,
) -> Result<dosh::native::HostPublicKey> {
let mut command = Command::new("ssh");
if let Some(ssh_port) = ssh_port {
command.arg("-p").arg(ssh_port.to_string());
}
command.arg("-T");
if let Some(key) = ssh_key {
command.arg("-i").arg(key);
}
if let Some(known_hosts) = ssh_known_hosts {
command
.arg("-o")
.arg(format!("UserKnownHostsFile={}", known_hosts.display()));
}
if let Some(control_path) = ssh_control_path {
command.arg("-S").arg(control_path);
}
let output = command
.arg(server)
.arg(ssh_auth_command)
.arg("--host-key")
.output()
.context("fetch Dosh host key over SSH")?;
if !output.status.success() {
return Err(anyhow!(
"Dosh host-key fetch failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let raw = String::from_utf8(output.stdout)?;
parse_host_public_key_line(&raw)
}
fn startup_command(args: &[String]) -> Option<Vec<u8>> {
if args.is_empty() {
return None;
+11 -2
View File
@@ -6,6 +6,7 @@ use dosh::auth::{
};
use dosh::config::{ServerConfig, load_server_config};
use dosh::crypto;
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody,
@@ -36,7 +37,9 @@ enum Command {
#[arg(long, default_value_t = 1)]
protocol: u8,
#[arg(long)]
nonce: String,
nonce: Option<String>,
#[arg(long)]
host_key: bool,
#[arg(long, default_value = "default")]
session: String,
#[arg(long, default_value = "read-write")]
@@ -58,6 +61,7 @@ async fn main() -> Result<()> {
Command::Auth {
protocol,
nonce,
host_key,
session,
mode,
size,
@@ -66,8 +70,13 @@ async fn main() -> Result<()> {
} => {
anyhow::ensure!(protocol == 1, "unsupported protocol {protocol}");
let config = load_server_config(None)?;
if host_key {
let signing_key = load_or_create_host_key(&config)?;
println!("{}", host_public_key_line(&host_public_key(&signing_key)));
return Ok(());
}
let secret = load_or_create_server_secret(&config)?;
let nonce = parse_nonce(&nonce)?;
let nonce = parse_nonce(nonce.as_deref().context("--nonce is required")?)?;
let size = parse_size(&size)?;
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let udp_host = udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
+77
View File
@@ -20,6 +20,20 @@ pub struct ServerConfig {
pub shell: String,
pub sessions_dir: String,
pub secret_path: String,
#[serde(default = "default_true")]
pub native_auth: bool,
#[serde(default = "default_host_key")]
pub host_key: String,
#[serde(default = "default_authorized_keys")]
pub authorized_keys: Vec<String>,
#[serde(default = "default_native_auth_rate_limit_per_minute")]
pub native_auth_rate_limit_per_minute: u32,
#[serde(default = "default_true")]
pub allow_tcp_forwarding: bool,
#[serde(default)]
pub allow_remote_forwarding: bool,
#[serde(default)]
pub allow_agent_forwarding: bool,
}
impl Default for ServerConfig {
@@ -39,6 +53,13 @@ impl Default for ServerConfig {
shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
sessions_dir: "~/.local/share/dosh/sessions".to_string(),
secret_path: "~/.config/dosh/secret".to_string(),
native_auth: true,
host_key: default_host_key(),
authorized_keys: default_authorized_keys(),
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
allow_tcp_forwarding: true,
allow_remote_forwarding: false,
allow_agent_forwarding: false,
}
}
}
@@ -59,6 +80,20 @@ pub struct ClientConfig {
pub predict: bool,
pub cache_attach_tickets: bool,
pub credential_cache: String,
#[serde(default = "default_auth_preference")]
pub auth_preference: String,
#[serde(default)]
pub trust_on_first_use: bool,
#[serde(default = "default_native_auth_timeout_ms")]
pub native_auth_timeout_ms: u64,
#[serde(default = "default_known_hosts")]
pub known_hosts: String,
#[serde(default = "default_identity_files")]
pub identity_files: Vec<String>,
#[serde(default = "default_true")]
pub use_ssh_agent: bool,
#[serde(default)]
pub forward_agent: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -93,10 +128,52 @@ impl Default for ClientConfig {
predict: false,
cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(),
auth_preference: default_auth_preference(),
trust_on_first_use: false,
native_auth_timeout_ms: default_native_auth_timeout_ms(),
known_hosts: default_known_hosts(),
identity_files: default_identity_files(),
use_ssh_agent: true,
forward_agent: false,
}
}
}
fn default_true() -> bool {
true
}
fn default_host_key() -> String {
"~/.config/dosh/host_key".to_string()
}
fn default_authorized_keys() -> Vec<String> {
vec![
"~/.ssh/authorized_keys".to_string(),
"~/.config/dosh/authorized_keys".to_string(),
]
}
fn default_native_auth_rate_limit_per_minute() -> u32 {
30
}
fn default_auth_preference() -> String {
"native,ssh".to_string()
}
fn default_native_auth_timeout_ms() -> u64 {
700
}
fn default_known_hosts() -> String {
"~/.config/dosh/known_hosts".to_string()
}
fn default_identity_files() -> Vec<String> {
vec!["~/.ssh/id_ed25519".to_string()]
}
pub fn load_server_config(path: Option<PathBuf>) -> Result<ServerConfig> {
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml"));
if !path.exists() {
+1
View File
@@ -1,5 +1,6 @@
pub mod auth;
pub mod config;
pub mod crypto;
pub mod native;
pub mod protocol;
pub mod pty;
+299
View File
@@ -0,0 +1,299 @@
use crate::auth::now_secs;
use crate::config::{ServerConfig, expand_tilde};
use crate::crypto;
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostPublicKey {
pub algorithm: String,
pub key: [u8; 32],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnownHost {
pub host: String,
pub algorithm: String,
pub key: [u8; 32],
pub first_seen: u64,
pub source: String,
}
pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
let path = expand_tilde(&config.host_key);
if path.exists() {
let raw = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let decoded = if raw.len() == 32 {
raw
} else {
URL_SAFE_NO_PAD
.decode(String::from_utf8_lossy(&raw).trim())
.context("decode Dosh host key")?
};
anyhow::ensure!(decoded.len() == 32, "Dosh host key must be 32 bytes");
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&decoded);
return Ok(SigningKey::from_bytes(&bytes));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let bytes = crypto::random_32();
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
file.write_all(b"\n")?;
Ok(SigningKey::from_bytes(&bytes))
}
pub fn host_public_key(signing_key: &SigningKey) -> HostPublicKey {
let verifying = VerifyingKey::from(signing_key);
HostPublicKey {
algorithm: HOST_KEY_ALGORITHM.to_string(),
key: verifying.to_bytes(),
}
}
pub fn host_public_key_line(key: &HostPublicKey) -> String {
format!(
"{} {} {}",
key.algorithm,
URL_SAFE_NO_PAD.encode(key.key),
host_fingerprint(key)
)
}
pub fn parse_host_public_key_line(raw: &str) -> Result<HostPublicKey> {
let mut parts = raw.split_whitespace();
let algorithm = parts.next().context("missing Dosh host key algorithm")?;
if algorithm != HOST_KEY_ALGORITHM {
bail!("unsupported Dosh host key algorithm {algorithm}");
}
let key_raw = parts.next().context("missing Dosh host public key")?;
let decoded = URL_SAFE_NO_PAD
.decode(key_raw)
.context("decode Dosh host public key")?;
anyhow::ensure!(decoded.len() == 32, "Dosh host public key must be 32 bytes");
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
Ok(HostPublicKey {
algorithm: algorithm.to_string(),
key,
})
}
pub fn host_fingerprint(key: &HostPublicKey) -> String {
format!(
"SHA256:{}",
URL_SAFE_NO_PAD.encode(crypto::sha256(&key.key))
)
}
pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String {
format!(
"{} {} {} first-seen={} source={}",
host,
key.algorithm,
URL_SAFE_NO_PAD.encode(key.key),
first_seen,
source
)
}
pub fn parse_known_hosts(raw: &str) -> Result<Vec<KnownHost>> {
raw.lines()
.enumerate()
.filter_map(|(index, line)| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
None
} else {
Some(parse_known_host_line(index + 1, line))
}
})
.collect()
}
fn parse_known_host_line(line_number: usize, line: &str) -> Result<KnownHost> {
let mut parts = line.split_whitespace();
let host = parts
.next()
.with_context(|| format!("known_hosts:{line_number}: missing host"))?;
let algorithm = parts
.next()
.with_context(|| format!("known_hosts:{line_number}: missing algorithm"))?;
if algorithm != HOST_KEY_ALGORITHM {
bail!("known_hosts:{line_number}: unsupported algorithm {algorithm}");
}
let key_raw = parts
.next()
.with_context(|| format!("known_hosts:{line_number}: missing key"))?;
let decoded = URL_SAFE_NO_PAD
.decode(key_raw)
.with_context(|| format!("known_hosts:{line_number}: decode key"))?;
anyhow::ensure!(
decoded.len() == 32,
"known_hosts:{line_number}: host key must be 32 bytes"
);
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
let mut first_seen = 0;
let mut source = "unknown".to_string();
for part in parts {
if let Some(value) = part.strip_prefix("first-seen=") {
first_seen = value.parse().unwrap_or(0);
} else if let Some(value) = part.strip_prefix("source=") {
source = value.to_string();
}
}
Ok(KnownHost {
host: host.to_string(),
algorithm: algorithm.to_string(),
key,
first_seen,
source,
})
}
pub fn trust_host(
path: &Path,
host: &str,
key: &HostPublicKey,
source: &str,
replace: bool,
) -> Result<TrustResult> {
let raw = if path.exists() {
fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?
} else {
String::new()
};
let mut entries = parse_known_hosts(&raw)?;
if let Some(existing) = entries.iter().find(|entry| entry.host == host) {
if existing.key == key.key && existing.algorithm == key.algorithm {
return Ok(TrustResult::AlreadyTrusted);
}
if !replace {
bail!(
"Dosh host key mismatch for {host}: existing {}, new {}",
host_fingerprint(&HostPublicKey {
algorithm: existing.algorithm.clone(),
key: existing.key,
}),
host_fingerprint(key)
);
}
entries.retain(|entry| entry.host != host);
}
entries.push(KnownHost {
host: host.to_string(),
algorithm: key.algorithm.clone(),
key: key.key,
first_seen: now_secs()?,
source: source.to_string(),
});
write_known_hosts(path, &entries)
}
pub fn remove_trusted_host(path: &Path, host: &str) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut entries = parse_known_hosts(&raw)?;
let before = entries.len();
entries.retain(|entry| entry.host != host);
if entries.len() == before {
return Ok(false);
}
write_known_host_entries(path, &entries)?;
Ok(true)
}
fn write_known_hosts(path: &Path, entries: &[KnownHost]) -> Result<TrustResult> {
write_known_host_entries(path, entries)?;
Ok(TrustResult::Trusted)
}
fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let mut out = String::new();
for entry in entries {
out.push_str(&known_host_line(
&entry.host,
&HostPublicKey {
algorithm: entry.algorithm.clone(),
key: entry.key,
},
&entry.source,
entry.first_seen,
));
out.push('\n');
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("write {}", path.display()))?;
file.write_all(out.as_bytes())
.with_context(|| format!("write {}", path.display()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustResult {
AlreadyTrusted,
Trusted,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_public_key_line_round_trips() {
let signing = SigningKey::from_bytes(&[7u8; 32]);
let public = host_public_key(&signing);
let line = host_public_key_line(&public);
let parsed = parse_host_public_key_line(&line).unwrap();
assert_eq!(parsed, public);
assert!(host_fingerprint(&public).starts_with("SHA256:"));
}
#[test]
fn known_host_trust_rejects_mismatch_without_replace() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known_hosts");
let first = host_public_key(&SigningKey::from_bytes(&[1u8; 32]));
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
TrustResult::Trusted
);
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted
);
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err());
assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(),
TrustResult::Trusted
);
}
}