Wire native Dosh auth runtime
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 11:54:24 -04:00
parent 80c7889b46
commit e7f3679fc1
8 changed files with 1086 additions and 11 deletions
+302 -4
View File
@@ -9,11 +9,14 @@ use dosh::auth::{
use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_server_config};
use dosh::crypto;
use dosh::native::{
TrustResult, host_fingerprint, parse_host_public_key_line, remove_trusted_host, trust_host,
KnownHostStatus, TrustResult, derive_native_session_key, generate_native_ephemeral,
host_fingerprint, load_ed25519_identity, parse_host_public_key_line, remove_trusted_host,
sign_user_auth, trust_host, verify_known_host, verify_server_hello,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind,
Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
};
use serde::{Deserialize, Serialize};
@@ -43,6 +46,8 @@ struct Args {
#[arg(long)]
local_auth: bool,
#[arg(long)]
auth: Option<String>,
#[arg(long)]
no_cache: bool,
#[arg(long)]
remove: bool,
@@ -237,6 +242,66 @@ async fn main() -> Result<()> {
}
}
let auth_preference = args
.auth
.clone()
.unwrap_or_else(|| config.auth_preference.clone());
if !args.local_auth && auth_allows(&auth_preference, "native") {
let native_start = Instant::now();
match try_native_auth(
&socket,
&config,
&requested_server,
&server,
ssh_port,
&target_udp_host,
dosh_port,
args.ssh_key.as_deref(),
&session,
&mode,
cols,
rows,
)
.await
{
Ok((frame, cred)) => {
log_timing(args.verbose, "native_auth", native_start.elapsed());
if !args.no_cache {
save_cache(&cache_path, &cred)?;
}
log_timing(args.verbose, "terminal_ready", started.elapsed());
if args.attach_only {
render_frame(&frame)?;
detach_once(&socket, &cred, 2).await?;
return Ok(());
}
return run_terminal(
socket,
cred,
Some(frame),
predict,
startup_command,
config.reconnect_timeout_secs,
)
.await;
}
Err(err) if auth_allows(&auth_preference, "ssh") => {
log_debug(
args.verbose,
2,
&format!("dosh native auth failed, falling back to SSH bootstrap: {err:#}"),
);
}
Err(err) => return Err(err.context("native auth failed")),
}
}
if !args.local_auth && !auth_allows(&auth_preference, "ssh") {
return Err(anyhow!(
"auth preference {auth_preference:?} does not allow SSH fallback"
));
}
let bootstrap_start = Instant::now();
let bootstrap = if args.local_auth {
local_bootstrap(&session, &mode, cols, rows, dosh_port, target_udp_host)?
@@ -565,6 +630,13 @@ fn local_bootstrap(
)
}
fn auth_allows(preference: &str, method: &str) -> bool {
preference
.split(',')
.map(str::trim)
.any(|entry| entry == "auto" || entry == method)
}
fn log_timing(verbose: u8, name: &str, elapsed: Duration) {
if verbose >= 1 {
eprintln!("dosh timing {name}={}ms", elapsed.as_millis());
@@ -663,6 +735,15 @@ fn ssh_destination_host(server: &str) -> String {
.to_string()
}
fn ssh_username(server: &str) -> Option<String> {
let (user, _) = server.rsplit_once('@')?;
if user.is_empty() {
None
} else {
Some(user.to_string())
}
}
fn ssh_config_hostname(server: &str, ssh_port: Option<u16>) -> Result<String> {
ssh_config(server, ssh_port)?
.hostname
@@ -673,6 +754,8 @@ fn ssh_config_hostname(server: &str, ssh_port: Option<u16>) -> Result<String> {
struct SshConfig {
hostname: Option<String>,
port: Option<u16>,
user: Option<String>,
identity_files: Vec<String>,
}
fn ssh_config(server: &str, ssh_port: Option<u16>) -> Result<SshConfig> {
@@ -707,6 +790,10 @@ fn parse_ssh_config(raw: &str) -> SshConfig {
config.hostname = Some(value.to_string());
} else if key.eq_ignore_ascii_case("port") {
config.port = value.parse().ok();
} else if key.eq_ignore_ascii_case("user") && !value.is_empty() {
config.user = Some(value.to_string());
} else if key.eq_ignore_ascii_case("identityfile") && !value.is_empty() {
config.identity_files.push(value.to_string());
}
}
config
@@ -720,6 +807,191 @@ fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> {
.ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}"))
}
async fn try_native_auth(
socket: &UdpSocket,
config: &dosh::config::ClientConfig,
requested_server: &str,
server: &str,
ssh_port: Option<u16>,
udp_host: &str,
udp_port: u16,
cli_identity: Option<&Path>,
session: &str,
mode: &str,
cols: u16,
rows: u16,
) -> Result<(Frame, CachedCredential)> {
let addr = resolve_addr(udp_host, udp_port)?;
let ssh_config = ssh_config(server, ssh_port).unwrap_or_default();
let requested_user = ssh_username(server)
.or(ssh_config.user.clone())
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
let (client_secret, client_public) = generate_native_ephemeral();
let hello = dosh::native::NativeClientHello {
protocol_version: dosh::native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: requested_server.to_string(),
requested_user,
requested_session: session.to_string(),
requested_mode: mode.to_string(),
terminal_size: (cols, rows),
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 packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})?,
)?;
socket.send_to(&packet, addr).await?;
let mut buf = vec![0u8; 65535];
let (n, _) = tokio::time::timeout(
Duration::from_millis(config.native_auth_timeout_ms.max(1)),
socket.recv_from(&mut buf),
)
.await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeServerHello {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
return Err(anyhow!("native auth rejected: {}", reject.reason));
}
return Err(anyhow!("native auth received unexpected server response"));
}
let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?;
verify_server_hello(&hello, &server_hello.hello)?;
let known_hosts = expand_tilde(&config.known_hosts);
match verify_known_host(&known_hosts, requested_server, &server_hello.hello.host_key)? {
KnownHostStatus::Trusted => {}
KnownHostStatus::Unknown if config.trust_on_first_use => {
trust_host(
&known_hosts,
requested_server,
&server_hello.hello.host_key,
"tofu",
false,
)?;
}
KnownHostStatus::Unknown => {
return Err(anyhow!(
"Dosh host key for {requested_server} is not trusted; run `dosh trust {requested_server}` first"
));
}
KnownHostStatus::Mismatch { expected, actual } => {
return Err(anyhow!(
"Dosh host key mismatch for {requested_server}: expected {expected}, got {actual}"
));
}
}
let session_key = derive_native_session_key(
&client_secret,
server_hello.hello.server_ephemeral_public,
&hello,
&server_hello.hello,
)?;
let identity = load_first_native_identity(config, cli_identity, &ssh_config)?;
let auth = sign_user_auth(&identity, &hello, &server_hello.hello, Vec::new())?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
let auth_body = protocol::to_body(&NativeUserAuthBody { auth })?;
let packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&auth_body,
)?;
socket.send_to(&packet, addr).await?;
let (n, _) = tokio::time::timeout(
Duration::from_millis(config.native_auth_timeout_ms.max(1)),
socket.recv_from(&mut buf),
)
.await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeAuthOk {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
return Err(anyhow!("native auth rejected: {}", reject.reason));
}
return Err(anyhow!("native auth received unexpected auth response"));
}
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?;
let ok: NativeAuthOkBody = protocol::from_body(&plain)?;
let frame = Frame {
session: ok.ok.session.clone(),
output_seq: ok.ok.initial_seq,
bytes: ok.ok.snapshot.clone(),
snapshot: true,
closed: false,
};
let cred = CachedCredential {
server: requested_server.to_string(),
session: ok.ok.session,
mode: ok.ok.mode,
udp_host: udp_host.to_string(),
udp_port,
client_id: ok.ok.client_id,
session_key: ok.ok.session_key,
session_key_id: ok.ok.session_key_id,
attach_ticket: ok.ok.attach_ticket,
attach_ticket_psk: ok.ok.attach_ticket_psk,
last_rendered_seq: ok.ok.initial_seq,
};
Ok((frame, cred))
}
fn load_first_native_identity(
config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>,
ssh_config: &SshConfig,
) -> Result<ed25519_dalek::SigningKey> {
let mut paths = Vec::new();
if let Some(path) = cli_identity {
paths.push(path.to_path_buf());
}
paths.extend(
ssh_config
.identity_files
.iter()
.map(|path| expand_tilde(path)),
);
paths.extend(config.identity_files.iter().map(|path| expand_tilde(path)));
paths.sort();
paths.dedup();
let mut errors = Vec::new();
for path in paths {
if !path.exists() {
continue;
}
match load_ed25519_identity(&path) {
Ok(identity) => return Ok(identity),
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
}
}
if errors.is_empty() {
Err(anyhow!("no usable ssh-ed25519 identity found"))
} else {
Err(anyhow!(
"no usable ssh-ed25519 identity found: {}",
errors.join("; ")
))
}
}
async fn bootstrap_attach(
socket: &UdpSocket,
server_name: &str,
@@ -1424,8 +1696,8 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
#[cfg(test)]
mod tests {
use super::{
FrameBuffer, Predictor, parse_ssh_config, raw_contains_host_table, ssh_destination_host,
startup_command, toml_bare_key_or_quoted,
FrameBuffer, Predictor, auth_allows, parse_ssh_config, raw_contains_host_table,
ssh_destination_host, ssh_username, startup_command, toml_bare_key_or_quoted,
};
use dosh::protocol::Frame;
@@ -1454,6 +1726,18 @@ mod tests {
assert_eq!(parsed.port, Some(2222));
}
#[test]
fn parses_ssh_config_user_and_identity_files() {
let parsed = parse_ssh_config(
"user palav\nidentityfile ~/.ssh/id_ed25519\nidentityfile ~/.ssh/work\n",
);
assert_eq!(parsed.user, Some("palav".to_string()));
assert_eq!(
parsed.identity_files,
vec!["~/.ssh/id_ed25519".to_string(), "~/.ssh/work".to_string()]
);
}
#[test]
fn detects_existing_imported_host_tables() {
assert!(raw_contains_host_table(
@@ -1470,6 +1754,20 @@ mod tests {
assert_eq!(ssh_destination_host("palav@example.com"), "example.com");
}
#[test]
fn parses_user_from_ssh_destination() {
assert_eq!(ssh_username("palav@example.com"), Some("palav".to_string()));
assert_eq!(ssh_username("example.com"), None);
}
#[test]
fn auth_preference_accepts_auto_or_named_method() {
assert!(auth_allows("native,ssh", "native"));
assert!(auth_allows("native,ssh", "ssh"));
assert!(auth_allows("auto", "native"));
assert!(!auth_allows("native", "ssh"));
}
fn test_frame(seq: u64, snapshot: bool) -> Frame {
Frame {
session: "test".to_string(),
+264 -5
View File
@@ -1,16 +1,21 @@
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use dosh::auth::{
build_bootstrap, encode_bootstrap, load_or_create_server_secret, open_attach_ticket,
verify_bootstrap,
build_attach_ticket, build_bootstrap, encode_bootstrap, load_or_create_server_secret, now_secs,
open_attach_ticket, verify_bootstrap,
};
use dosh::config::{ServerConfig, load_server_config};
use dosh::crypto;
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
use dosh::native::{
NativeAuthOk, NativeServerHello, derive_native_session_key, generate_native_ephemeral,
host_public_key, host_public_key_line, load_or_create_host_key, sign_server_hello,
verify_native_user_auth_from_config,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody,
TicketAttachEnvelope, TicketAttachOkEnvelope,
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind,
ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
};
use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session};
use std::collections::{HashMap, VecDeque};
@@ -157,6 +162,7 @@ struct ServerState {
secret: [u8; 32],
pty_tx: mpsc::UnboundedSender<PtyOutput>,
sessions: HashMap<String, Session>,
pending_native: HashMap<[u8; 16], PendingNativeAuth>,
}
struct Session {
@@ -190,6 +196,14 @@ struct PendingFrame {
attempts: u8,
}
struct PendingNativeAuth {
client: dosh::native::NativeClientHello,
server: NativeServerHello,
session_key: [u8; 32],
peer: SocketAddr,
created_at: Instant,
}
impl ServerState {
fn new(
config: ServerConfig,
@@ -201,6 +215,7 @@ impl ServerState {
secret,
pty_tx,
sessions: HashMap::new(),
pending_native: HashMap::new(),
}
}
@@ -237,6 +252,10 @@ async fn handle_packet(
) -> Result<()> {
let packet = protocol::decode(raw)?;
match packet.header.kind {
PacketKind::NativeClientHello => {
handle_native_client_hello(state, socket, peer, packet.body).await
}
PacketKind::NativeUserAuth => handle_native_user_auth(state, socket, peer, &packet).await,
PacketKind::BootstrapAttachRequest => {
handle_bootstrap_attach(state, socket, peer, packet.body).await
}
@@ -258,6 +277,246 @@ async fn handle_packet(
}
}
async fn handle_native_client_hello(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
peer: SocketAddr,
body: Vec<u8>,
) -> Result<()> {
let req: NativeClientHelloBody = protocol::from_body(&body)?;
let built: Result<([u8; 16], NativeServerHello)> = {
let mut locked = state.lock().expect("server state poisoned");
if !locked.config.native_auth {
Err(anyhow!("native auth disabled"))
} else if !req
.hello
.supported_aead
.iter()
.any(|algorithm| algorithm == "chacha20poly1305")
{
Err(anyhow!("native auth requires chacha20poly1305"))
} else if !req
.hello
.supported_user_key_algorithms
.iter()
.any(|algorithm| algorithm == "ssh-ed25519")
{
Err(anyhow!("native auth requires ssh-ed25519"))
} else {
let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
if req.hello.requested_user != current_user {
Err(anyhow!("native auth user mismatch"))
} else {
let host_signing = load_or_create_host_key(&locked.config)?;
let (server_secret, server_public) = generate_native_ephemeral();
let mut hello = NativeServerHello {
protocol_version: dosh::native::NATIVE_PROTOCOL_VERSION,
server_random: crypto::random_32(),
server_ephemeral_public: server_public,
host_key: host_public_key(&host_signing),
chosen_aead: "chacha20poly1305".to_string(),
server_key_epoch: 1,
auth_challenge: crypto::random_32(),
rate_limit_remaining: Some(locked.config.native_auth_rate_limit_per_minute),
host_signature: Vec::new(),
};
sign_server_hello(&host_signing, &req.hello, &mut hello)?;
let session_key = derive_native_session_key(
&server_secret,
req.hello.client_ephemeral_public,
&req.hello,
&hello,
)?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&hello.auth_challenge[..16]);
locked.pending_native.insert(
pending_id,
PendingNativeAuth {
client: req.hello,
server: hello.clone(),
session_key,
peer,
created_at: Instant::now(),
},
);
Ok((pending_id, hello))
}
}
};
let (pending_id, hello) = match built {
Ok(built) => built,
Err(err) => return send_reject(socket, peer, &err.to_string()).await,
};
let body = protocol::to_body(&NativeServerHelloBody { hello })?;
let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?;
socket.send_to(&out, peer).await?;
Ok(())
}
async fn handle_native_user_auth(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
peer: SocketAddr,
packet: &protocol::Packet,
) -> Result<()> {
let pending = {
let mut locked = state.lock().expect("server state poisoned");
locked.pending_native.remove(&packet.header.conn_id)
};
let Some(pending) = pending else {
return send_reject_to_client(socket, peer, packet.header.conn_id, "unknown native auth")
.await;
};
if pending.peer != peer {
return send_reject_to_client(
socket,
peer,
packet.header.conn_id,
"native auth peer changed",
)
.await;
}
if pending.created_at.elapsed() > Duration::from_secs(30) {
return send_reject_to_client(socket, peer, packet.header.conn_id, "native auth expired")
.await;
}
let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?;
let req: NativeUserAuthBody = protocol::from_body(&body)?;
let attached: Result<(
[u8; 16],
[u8; 16],
Vec<u8>,
[u8; 32],
String,
String,
u64,
Vec<u8>,
)> = {
let mut locked = state.lock().expect("server state poisoned");
verify_native_user_auth_from_config(
&locked.config,
&pending.client,
&pending.server,
&req.auth,
)
.context("verify native user auth")
.and_then(|_| {
let session_name = pending.client.requested_session.clone();
let mode = pending.client.requested_mode.clone();
let cols = pending.client.terminal_size.0;
let rows = pending.client.terminal_size.1;
if !locked.sessions.contains_key(&session_name) {
if locked.config.create_on_attach {
locked.ensure_session(&session_name, cols, rows)?;
} else {
return Err(anyhow!("session does not exist"));
}
}
let session_key = pending.session_key;
let key_id = {
let digest = crypto::sha256(&session_key);
let mut out = [0u8; 16];
out.copy_from_slice(&digest[..16]);
out
};
let attach_ticket_psk = crypto::random_32();
let issued_at = now_secs()?;
let attach_ticket = build_attach_ticket(
&locked.secret,
crypto::sha256(&locked.secret),
1,
pending.client.requested_user.clone(),
session_name.clone(),
mode.clone(),
issued_at,
issued_at + locked.config.attach_ticket_ttl_secs,
&attach_ticket_psk,
)?;
let session = locked
.sessions
.get_mut(&session_name)
.expect("session exists");
if mode != "view-only" {
session.pty.resize(cols, rows)?;
session.parser.set_size(rows, cols);
}
let client_id = crypto::random_16();
let snapshot = screen_snapshot(session.parser.screen());
let screen = session.parser.screen().clone();
let output_seq = session.output_seq;
session.clients.insert(
client_id,
ClientState {
endpoint: peer,
mode: mode.clone(),
session_key,
last_acked: output_seq,
replay: ReplayWindow::default(),
send_seq: 1,
cols,
rows,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_screen: Some(screen),
},
);
Ok((
client_id,
key_id,
attach_ticket,
attach_ticket_psk,
session_name,
mode,
output_seq,
snapshot,
))
})
};
let (
client_id,
key_id,
attach_ticket,
attach_ticket_psk,
session_name,
mode,
output_seq,
snapshot,
) = match attached {
Ok(attached) => attached,
Err(err) => {
return send_reject_to_client(socket, peer, packet.header.conn_id, &err.to_string())
.await;
}
};
let ok = NativeAuthOk {
client_id,
session: session_name,
mode,
session_key: pending.session_key,
session_key_id: key_id,
attach_ticket,
attach_ticket_psk,
initial_seq: output_seq,
snapshot,
policy_flags: Vec::new(),
};
let body = protocol::to_body(&NativeAuthOkBody { ok })?;
let out = protocol::encode_encrypted(
PacketKind::NativeAuthOk,
client_id,
1,
packet.header.seq,
&pending.session_key,
SERVER_TO_CLIENT,
&body,
)?;
socket.send_to(&out, peer).await?;
Ok(())
}
async fn handle_bootstrap_attach(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,