Add native Dosh handshake skeleton
This commit is contained in:
+217
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user