From 80c7889b46489a554f9cf58b34f6dae05e86e2b1 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:38:00 -0400 Subject: [PATCH] Verify native Dosh Ed25519 user auth --- src/native.rs | 439 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 429 insertions(+), 10 deletions(-) diff --git a/src/native.rs b/src/native.rs index a328201..85a5757 100644 --- a/src/native.rs +++ b/src/native.rs @@ -3,7 +3,7 @@ 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 base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use std::fs; @@ -95,6 +95,24 @@ pub struct NativeAuthOk { pub policy_flags: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizedKey { + pub algorithm: String, + pub key: Vec, + pub options: AuthorizedKeyOptions, + pub comment: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthorizedKeyOptions { + pub from: Option, + pub force_command: Option, + pub restricted: bool, + pub no_port_forwarding: bool, + pub permitopen: Vec, + pub unsupported: Vec, +} + pub fn load_or_create_host_key(config: &ServerConfig) -> Result { let path = expand_tilde(&config.host_key); if path.exists() { @@ -233,6 +251,296 @@ pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHell verify_host_signature(&server.host_key, &transcript, &server.host_signature) } +pub fn user_auth_transcript( + client: &NativeClientHello, + server: &NativeServerHello, + auth: &NativeUserAuth, +) -> Result> { + let mut unsigned = auth.clone(); + unsigned.signature.clear(); + let mut out = b"dosh/native/user-auth/v1".to_vec(); + out.extend(bincode::serialize(client)?); + out.extend(bincode::serialize(server)?); + out.extend(bincode::serialize(&unsigned)?); + Ok(out) +} + +pub fn sign_user_auth( + signing_key: &SigningKey, + client: &NativeClientHello, + server: &NativeServerHello, + requested_forwardings: Vec, +) -> Result { + let verifying = VerifyingKey::from(signing_key); + let mut auth = NativeUserAuth { + public_key_algorithm: "ssh-ed25519".to_string(), + public_key: verifying.to_bytes().to_vec(), + signature: Vec::new(), + requested_forwardings, + }; + let transcript = user_auth_transcript(client, server, &auth)?; + auth.signature = signing_key.sign(&transcript).to_bytes().to_vec(); + Ok(auth) +} + +pub fn verify_native_user_auth( + client: &NativeClientHello, + server: &NativeServerHello, + auth: &NativeUserAuth, + authorized_keys: &[AuthorizedKey], +) -> Result { + anyhow::ensure!( + auth.public_key_algorithm == "ssh-ed25519", + "unsupported native user key algorithm {}", + auth.public_key_algorithm + ); + anyhow::ensure!( + auth.public_key.len() == 32, + "ssh-ed25519 public key must be 32 bytes" + ); + anyhow::ensure!( + auth.signature.len() == 64, + "ssh-ed25519 signature must be 64 bytes" + ); + let authorized = authorized_keys + .iter() + .find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key) + .ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?; + authorized.ensure_native_allowed(auth)?; + + let mut public_key = [0u8; 32]; + public_key.copy_from_slice(&auth.public_key); + let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?; + let mut signature = [0u8; 64]; + signature.copy_from_slice(&auth.signature); + let signature = Signature::from_bytes(&signature); + let transcript = user_auth_transcript(client, server, auth)?; + verifying_key + .verify(&transcript, &signature) + .context("verify native user signature")?; + Ok(authorized.clone()) +} + +pub fn verify_native_user_auth_from_config( + config: &ServerConfig, + client: &NativeClientHello, + server: &NativeServerHello, + auth: &NativeUserAuth, +) -> Result { + let authorized_keys = load_authorized_keys(&config.authorized_keys)?; + verify_native_user_auth(client, server, auth, &authorized_keys) +} + +impl AuthorizedKey { + fn ensure_native_allowed(&self, auth: &NativeUserAuth) -> Result<()> { + if !self.options.unsupported.is_empty() { + bail!( + "authorized key has unsupported options: {}", + self.options.unsupported.join(",") + ); + } + if self.options.force_command.is_some() { + bail!("authorized key command= is not supported for native Dosh terminal login"); + } + if self.options.no_port_forwarding + && auth + .requested_forwardings + .iter() + .any(|forwarding| !matches!(forwarding.kind, ForwardingKind::Dynamic)) + { + bail!("authorized key forbids port forwarding"); + } + if !self.options.permitopen.is_empty() { + for forwarding in &auth.requested_forwardings { + if matches!(forwarding.kind, ForwardingKind::Local) + && let (Some(host), Some(port)) = + (forwarding.target_host.as_ref(), forwarding.target_port) + { + let target = format!("{host}:{port}"); + if !self + .options + .permitopen + .iter() + .any(|permit| permit == &target) + { + bail!("authorized key does not permit opening {target}"); + } + } + } + } + Ok(()) + } +} + +pub fn load_authorized_keys(paths: &[String]) -> Result> { + let mut out = Vec::new(); + for path in paths { + let path = expand_tilde(path); + if !path.exists() { + continue; + } + let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + out.extend( + parse_authorized_keys(&raw).with_context(|| format!("parse {}", path.display()))?, + ); + } + Ok(out) +} + +pub fn parse_authorized_keys(raw: &str) -> Result> { + raw.lines() + .enumerate() + .filter_map(|(index, line)| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + None + } else { + Some(parse_authorized_key_line(index + 1, line)) + } + }) + .collect() +} + +fn parse_authorized_key_line(line_number: usize, line: &str) -> Result { + let fields = split_authorized_key_fields(line); + anyhow::ensure!( + fields.len() >= 2, + "authorized_keys:{line_number}: expected key fields" + ); + let (options, key_type_index) = if fields[0].starts_with("ssh-") { + (AuthorizedKeyOptions::default(), 0) + } else { + (parse_authorized_key_options(&fields[0])?, 1) + }; + let algorithm = fields + .get(key_type_index) + .with_context(|| format!("authorized_keys:{line_number}: missing key type"))?; + let key_blob = fields + .get(key_type_index + 1) + .with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?; + anyhow::ensure!( + algorithm == "ssh-ed25519", + "authorized_keys:{line_number}: unsupported key type {algorithm}" + ); + let decoded = STANDARD + .decode(key_blob) + .with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?; + let key = parse_ssh_ed25519_public_blob(&decoded) + .with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?; + let comment = if fields.len() > key_type_index + 2 { + Some(fields[key_type_index + 2..].join(" ")) + } else { + None + }; + Ok(AuthorizedKey { + algorithm: algorithm.to_string(), + key: key.to_vec(), + options, + comment, + }) +} + +fn split_authorized_key_fields(line: &str) -> Vec { + line.split_whitespace().map(ToString::to_string).collect() +} + +fn parse_authorized_key_options(raw: &str) -> Result { + let mut options = AuthorizedKeyOptions::default(); + for option in split_options(raw)? { + if option == "restrict" { + options.restricted = true; + } else if option == "no-port-forwarding" { + options.no_port_forwarding = true; + } else if let Some(value) = option.strip_prefix("from=") { + options.from = Some(strip_quotes(value).to_string()); + } else if let Some(value) = option.strip_prefix("command=") { + options.force_command = Some(strip_quotes(value).to_string()); + } else if let Some(value) = option.strip_prefix("permitopen=") { + options.permitopen.push(strip_quotes(value).to_string()); + } else { + options.unsupported.push(option); + } + } + Ok(options) +} + +fn split_options(raw: &str) -> Result> { + let mut out = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + let mut escaped = false; + for ch in raw.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + if ch == '\\' && quoted { + escaped = true; + continue; + } + if ch == '"' { + quoted = !quoted; + current.push(ch); + continue; + } + if ch == ',' && !quoted { + out.push(current); + current = String::new(); + continue; + } + current.push(ch); + } + anyhow::ensure!(!quoted, "unterminated authorized_keys option quote"); + if !current.is_empty() { + out.push(current); + } + Ok(out) +} + +fn strip_quotes(raw: &str) -> &str { + raw.strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(raw) +} + +fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> { + let mut cursor = blob; + let key_type = read_ssh_string(&mut cursor)?; + anyhow::ensure!(key_type == b"ssh-ed25519", "key blob type mismatch"); + let key = read_ssh_string(&mut cursor)?; + anyhow::ensure!(key.len() == 32, "ssh-ed25519 key must be 32 bytes"); + anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-ed25519 key blob"); + let mut out = [0u8; 32]; + out.copy_from_slice(key); + Ok(out) +} + +fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { + anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length"); + let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize; + *cursor = &cursor[4..]; + anyhow::ensure!(cursor.len() >= len, "truncated SSH string body"); + let (value, rest) = cursor.split_at(len); + *cursor = rest; + Ok(value) +} + +#[cfg(test)] +fn ssh_ed25519_authorized_key_line(signing_key: &SigningKey) -> String { + let verifying = VerifyingKey::from(signing_key); + let mut blob = Vec::new(); + write_ssh_string(&mut blob, b"ssh-ed25519"); + write_ssh_string(&mut blob, &verifying.to_bytes()); + format!("ssh-ed25519 {} test-key", STANDARD.encode(blob)) +} + +#[cfg(test)] +fn write_ssh_string(out: &mut Vec, value: &[u8]) { + out.extend_from_slice(&(value.len() as u32).to_be_bytes()); + out.extend_from_slice(value); +} + pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String { format!( "{} {} {} first-seen={} source={}", @@ -482,7 +790,119 @@ mod tests { #[test] fn server_hello_signature_round_trips() { let signing = SigningKey::from_bytes(&[3u8; 32]); - let client = NativeClientHello { + let client = test_client_hello(); + let mut server = test_server_hello(&signing); + 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()); + } + + #[test] + fn authorized_keys_parses_ed25519() { + let signing = SigningKey::from_bytes(&[8u8; 32]); + let raw = ssh_ed25519_authorized_key_line(&signing); + let parsed = parse_authorized_keys(&raw).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].algorithm, "ssh-ed25519"); + assert_eq!( + parsed[0].key, + VerifyingKey::from(&signing).to_bytes().to_vec() + ); + assert_eq!(parsed[0].comment.as_deref(), Some("test-key")); + } + + #[test] + fn native_user_auth_accepts_authorized_key_and_rejects_removed_key() { + let host_signing = SigningKey::from_bytes(&[3u8; 32]); + let user_signing = SigningKey::from_bytes(&[9u8; 32]); + let other_signing = SigningKey::from_bytes(&[10u8; 32]); + let client = test_client_hello(); + let mut server = test_server_hello(&host_signing); + sign_server_hello(&host_signing, &client, &mut server).unwrap(); + let auth = sign_user_auth(&user_signing, &client, &server, Vec::new()).unwrap(); + let authorized = + parse_authorized_keys(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap(); + + verify_native_user_auth(&client, &server, &auth, &authorized).unwrap(); + + let removed = + parse_authorized_keys(&ssh_ed25519_authorized_key_line(&other_signing)).unwrap(); + assert!(verify_native_user_auth(&client, &server, &auth, &removed).is_err()); + } + + #[test] + fn native_user_auth_rejects_tampered_transcript() { + let host_signing = SigningKey::from_bytes(&[3u8; 32]); + let user_signing = SigningKey::from_bytes(&[9u8; 32]); + let client = test_client_hello(); + let mut server = test_server_hello(&host_signing); + sign_server_hello(&host_signing, &client, &mut server).unwrap(); + let auth = sign_user_auth(&user_signing, &client, &server, Vec::new()).unwrap(); + let authorized = + parse_authorized_keys(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap(); + let mut tampered = client.clone(); + tampered.requested_session = "other".to_string(); + + assert!(verify_native_user_auth(&tampered, &server, &auth, &authorized).is_err()); + } + + #[test] + fn native_user_auth_fails_closed_on_unsupported_authorized_key_options() { + let host_signing = SigningKey::from_bytes(&[3u8; 32]); + let user_signing = SigningKey::from_bytes(&[9u8; 32]); + let client = test_client_hello(); + let mut server = test_server_hello(&host_signing); + sign_server_hello(&host_signing, &client, &mut server).unwrap(); + let auth = sign_user_auth(&user_signing, &client, &server, Vec::new()).unwrap(); + let raw = format!( + "no-agent-forwarding {}", + ssh_ed25519_authorized_key_line(&user_signing) + ); + let authorized = parse_authorized_keys(&raw).unwrap(); + + assert!(verify_native_user_auth(&client, &server, &auth, &authorized).is_err()); + } + + #[test] + fn native_user_auth_enforces_no_port_forwarding_and_permitopen() { + let host_signing = SigningKey::from_bytes(&[3u8; 32]); + let user_signing = SigningKey::from_bytes(&[9u8; 32]); + let client = test_client_hello(); + let mut server = test_server_hello(&host_signing); + sign_server_hello(&host_signing, &client, &mut server).unwrap(); + let forward = ForwardingRequest { + kind: ForwardingKind::Local, + bind_host: None, + listen_port: 8080, + target_host: Some("127.0.0.1".to_string()), + target_port: Some(80), + }; + let auth = sign_user_auth(&user_signing, &client, &server, vec![forward]).unwrap(); + let no_forwarding = parse_authorized_keys(&format!( + "no-port-forwarding {}", + ssh_ed25519_authorized_key_line(&user_signing) + )) + .unwrap(); + assert!(verify_native_user_auth(&client, &server, &auth, &no_forwarding).is_err()); + + let permitted = parse_authorized_keys(&format!( + "permitopen=\"127.0.0.1:80\" {}", + ssh_ed25519_authorized_key_line(&user_signing) + )) + .unwrap(); + verify_native_user_auth(&client, &server, &auth, &permitted).unwrap(); + + let denied = parse_authorized_keys(&format!( + "permitopen=\"127.0.0.1:81\" {}", + ssh_ed25519_authorized_key_line(&user_signing) + )) + .unwrap(); + assert!(verify_native_user_auth(&client, &server, &auth, &denied).is_err()); + } + + fn test_client_hello() -> NativeClientHello { + NativeClientHello { protocol_version: NATIVE_PROTOCOL_VERSION, client_random: [1u8; 32], client_ephemeral_public: [2u8; 32], @@ -495,21 +915,20 @@ mod tests { supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], cached_host_key_fingerprint: None, attach_ticket_envelope: None, - }; - let mut server = NativeServerHello { + } + } + + fn test_server_hello(signing: &SigningKey) -> NativeServerHello { + NativeServerHello { protocol_version: NATIVE_PROTOCOL_VERSION, server_random: [4u8; 32], server_ephemeral_public: [5u8; 32], - host_key: host_public_key(&signing), + 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()); + } } }