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
+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
);
}
}