Add native Dosh handshake skeleton
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 11:34:05 -04:00
parent 26fc863149
commit a72f7eacf5
+217 -1
View File
@@ -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<String>,
pub supported_user_key_algorithms: Vec<String>,
pub cached_host_key_fingerprint: Option<String>,
pub attach_ticket_envelope: Option<Vec<u8>>,
}
#[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<u32>,
pub host_signature: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeUserAuth {
pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub signature: Vec<u8>,
pub requested_forwardings: Vec<ForwardingRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForwardingRequest {
pub kind: ForwardingKind,
pub bind_host: Option<String>,
pub listen_port: u16,
pub target_host: Option<String>,
pub target_port: Option<u16>,
}
#[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<u8>,
pub encrypted_attach_ticket_psk: Vec<u8>,
pub initial_seq: u64,
pub snapshot: Vec<u8>,
pub policy_flags: Vec<String>,
}
pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
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<Vec<u8>> {
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<Vec<KnownHost>> {
.collect()
}
pub fn verify_known_host(path: &Path, host: &str, key: &HostPublicKey) -> Result<KnownHostStatus> {
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<KnownHost> {
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());
}
}