Add native Dosh host trust foundation
This commit is contained in:
+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