Wire native Dosh auth runtime
This commit is contained in:
+302
-4
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user