Add native Dosh host trust foundation
This commit is contained in:
+10
-2
@@ -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());
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod native;
|
||||
pub mod protocol;
|
||||
pub mod pty;
|
||||
|
||||
+299
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user