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 anyhow::{Context, Result, bail};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
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 serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -12,6 +12,7 @@ use std::os::unix::fs::OpenOptionsExt;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
||||||
|
pub const NATIVE_PROTOCOL_VERSION: u8 = 1;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct HostPublicKey {
|
pub struct HostPublicKey {
|
||||||
@@ -28,6 +29,72 @@ pub struct KnownHost {
|
|||||||
pub source: String,
|
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> {
|
pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
|
||||||
let path = expand_tilde(&config.host_key);
|
let path = expand_tilde(&config.host_key);
|
||||||
if path.exists() {
|
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 {
|
pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{} {} {} first-seen={} source={}",
|
"{} {} {} first-seen={} source={}",
|
||||||
@@ -127,6 +258,28 @@ pub fn parse_known_hosts(raw: &str) -> Result<Vec<KnownHost>> {
|
|||||||
.collect()
|
.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> {
|
fn parse_known_host_line(line_number: usize, line: &str) -> Result<KnownHost> {
|
||||||
let mut parts = line.split_whitespace();
|
let mut parts = line.split_whitespace();
|
||||||
let host = parts
|
let host = parts
|
||||||
@@ -261,6 +414,13 @@ pub enum TrustResult {
|
|||||||
Trusted,
|
Trusted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum KnownHostStatus {
|
||||||
|
Unknown,
|
||||||
|
Trusted,
|
||||||
|
Mismatch { expected: String, actual: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -296,4 +456,60 @@ mod tests {
|
|||||||
TrustResult::Trusted
|
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