diff --git a/src/native.rs b/src/native.rs index 9f39a93..a328201 100644 --- a/src/native.rs +++ b/src/native.rs @@ -4,7 +4,7 @@ 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 ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; @@ -12,6 +12,7 @@ use std::os::unix::fs::OpenOptionsExt; use std::path::Path; pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519"; +pub const NATIVE_PROTOCOL_VERSION: u8 = 1; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HostPublicKey { @@ -28,6 +29,72 @@ pub struct KnownHost { pub source: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeClientHello { + pub protocol_version: u8, + pub client_random: [u8; 32], + pub client_ephemeral_public: [u8; 32], + pub requested_host: String, + pub requested_user: String, + pub requested_session: String, + pub requested_mode: String, + pub terminal_size: (u16, u16), + pub supported_aead: Vec, + pub supported_user_key_algorithms: Vec, + pub cached_host_key_fingerprint: Option, + pub attach_ticket_envelope: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeServerHello { + pub protocol_version: u8, + pub server_random: [u8; 32], + pub server_ephemeral_public: [u8; 32], + pub host_key: HostPublicKey, + pub chosen_aead: String, + pub server_key_epoch: u64, + pub auth_challenge: [u8; 32], + pub rate_limit_remaining: Option, + pub host_signature: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeUserAuth { + pub public_key_algorithm: String, + pub public_key: Vec, + pub signature: Vec, + pub requested_forwardings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForwardingRequest { + pub kind: ForwardingKind, + pub bind_host: Option, + pub listen_port: u16, + pub target_host: Option, + pub target_port: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ForwardingKind { + Local, + Remote, + Dynamic, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeAuthOk { + pub client_id: [u8; 16], + pub session: String, + pub mode: String, + pub session_key_id: [u8; 16], + pub attach_ticket: Vec, + pub encrypted_attach_ticket_psk: Vec, + pub initial_seq: u64, + pub snapshot: Vec, + pub policy_flags: Vec, +} + pub fn load_or_create_host_key(config: &ServerConfig) -> Result { let path = expand_tilde(&config.host_key); if path.exists() { @@ -102,6 +169,70 @@ pub fn host_fingerprint(key: &HostPublicKey) -> String { ) } +pub fn sign_host_transcript(signing_key: &SigningKey, transcript: &[u8]) -> [u8; 64] { + signing_key.sign(transcript).to_bytes() +} + +pub fn verify_host_signature( + host_key: &HostPublicKey, + transcript: &[u8], + signature: &[u8], +) -> Result<()> { + if host_key.algorithm != HOST_KEY_ALGORITHM { + bail!("unsupported Dosh host key algorithm {}", host_key.algorithm); + } + let verifying_key = + VerifyingKey::from_bytes(&host_key.key).context("parse Dosh host verifying key")?; + anyhow::ensure!( + signature.len() == 64, + "Dosh host signature must be 64 bytes" + ); + let mut signature_bytes = [0u8; 64]; + signature_bytes.copy_from_slice(signature); + let signature = Signature::from_bytes(&signature_bytes); + verifying_key + .verify(transcript, &signature) + .context("verify Dosh host signature") +} + +pub fn server_hello_transcript( + client: &NativeClientHello, + server: &NativeServerHello, +) -> Result> { + let mut unsigned = server.clone(); + unsigned.host_signature.clear(); + let mut out = b"dosh/native/server-hello/v1".to_vec(); + out.extend(bincode::serialize(client)?); + out.extend(bincode::serialize(&unsigned)?); + Ok(out) +} + +pub fn sign_server_hello( + signing_key: &SigningKey, + client: &NativeClientHello, + server: &mut NativeServerHello, +) -> Result<()> { + server.host_signature.clear(); + let transcript = server_hello_transcript(client, server)?; + server.host_signature = sign_host_transcript(signing_key, &transcript).to_vec(); + Ok(()) +} + +pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> { + anyhow::ensure!( + client.protocol_version == NATIVE_PROTOCOL_VERSION, + "unsupported native client protocol {}", + client.protocol_version + ); + anyhow::ensure!( + server.protocol_version == NATIVE_PROTOCOL_VERSION, + "unsupported native server protocol {}", + server.protocol_version + ); + let transcript = server_hello_transcript(client, server)?; + verify_host_signature(&server.host_key, &transcript, &server.host_signature) +} + pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String { format!( "{} {} {} first-seen={} source={}", @@ -127,6 +258,28 @@ pub fn parse_known_hosts(raw: &str) -> Result> { .collect() } +pub fn verify_known_host(path: &Path, host: &str, key: &HostPublicKey) -> Result { + if !path.exists() { + return Ok(KnownHostStatus::Unknown); + } + let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let entries = parse_known_hosts(&raw)?; + let Some(existing) = entries.iter().find(|entry| entry.host == host) else { + return Ok(KnownHostStatus::Unknown); + }; + if existing.algorithm == key.algorithm && existing.key == key.key { + Ok(KnownHostStatus::Trusted) + } else { + Ok(KnownHostStatus::Mismatch { + expected: host_fingerprint(&HostPublicKey { + algorithm: existing.algorithm.clone(), + key: existing.key, + }), + actual: host_fingerprint(key), + }) + } +} + fn parse_known_host_line(line_number: usize, line: &str) -> Result { let mut parts = line.split_whitespace(); let host = parts @@ -261,6 +414,13 @@ pub enum TrustResult { Trusted, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KnownHostStatus { + Unknown, + Trusted, + Mismatch { expected: String, actual: String }, +} + #[cfg(test)] mod tests { use super::*; @@ -296,4 +456,60 @@ mod tests { TrustResult::Trusted ); } + + #[test] + fn known_host_verification_reports_trusted_unknown_and_mismatch() { + 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!( + verify_known_host(&path, "palav", &first).unwrap(), + KnownHostStatus::Unknown + ); + trust_host(&path, "palav", &first, "ssh", false).unwrap(); + assert_eq!( + verify_known_host(&path, "palav", &first).unwrap(), + KnownHostStatus::Trusted + ); + assert!(matches!( + verify_known_host(&path, "palav", &second).unwrap(), + KnownHostStatus::Mismatch { .. } + )); + } + + #[test] + fn server_hello_signature_round_trips() { + let signing = SigningKey::from_bytes(&[3u8; 32]); + let client = NativeClientHello { + protocol_version: NATIVE_PROTOCOL_VERSION, + client_random: [1u8; 32], + client_ephemeral_public: [2u8; 32], + requested_host: "palav".to_string(), + requested_user: "palav".to_string(), + requested_session: "term".to_string(), + requested_mode: "read-write".to_string(), + terminal_size: (80, 24), + supported_aead: vec!["chacha20poly1305".to_string()], + supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], + cached_host_key_fingerprint: None, + attach_ticket_envelope: None, + }; + let mut server = NativeServerHello { + protocol_version: NATIVE_PROTOCOL_VERSION, + server_random: [4u8; 32], + server_ephemeral_public: [5u8; 32], + host_key: host_public_key(&signing), + chosen_aead: "chacha20poly1305".to_string(), + server_key_epoch: 1, + auth_challenge: [6u8; 32], + rate_limit_remaining: Some(30), + host_signature: Vec::new(), + }; + sign_server_hello(&signing, &client, &mut server).unwrap(); + verify_server_hello(&client, &server).unwrap(); + server.auth_challenge = [7u8; 32]; + assert!(verify_server_hello(&client, &server).is_err()); + } }