Add native Dosh host trust foundation
This commit is contained in:
Generated
+118
@@ -86,6 +86,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bincode"
|
name = "bincode"
|
||||||
version = "1.3.3"
|
version = "1.3.3"
|
||||||
@@ -209,6 +215,12 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -254,6 +266,43 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -298,6 +347,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"ed25519-dalek",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
@@ -316,6 +366,30 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -338,6 +412,12 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -630,6 +710,16 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "poly1305"
|
name = "poly1305"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -755,6 +845,15 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
@@ -945,6 +1044,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -961,6 +1069,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ chacha20poly1305 = "0.10"
|
|||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
|
ed25519-dalek = "2.1"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
portable-pty = "0.8"
|
portable-pty = "0.8"
|
||||||
|
|||||||
+14
@@ -214,6 +214,13 @@ create_on_attach = true
|
|||||||
shell = "$login_shell"
|
shell = "$login_shell"
|
||||||
sessions_dir = "~/.local/share/dosh/sessions"
|
sessions_dir = "~/.local/share/dosh/sessions"
|
||||||
secret_path = "~/.config/dosh/secret"
|
secret_path = "~/.config/dosh/secret"
|
||||||
|
native_auth = true
|
||||||
|
host_key = "~/.config/dosh/host_key"
|
||||||
|
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
|
||||||
|
native_auth_rate_limit_per_minute = 30
|
||||||
|
allow_tcp_forwarding = true
|
||||||
|
allow_remote_forwarding = false
|
||||||
|
allow_agent_forwarding = false
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -268,6 +275,13 @@ view_only = false
|
|||||||
predict = false
|
predict = false
|
||||||
cache_attach_tickets = true
|
cache_attach_tickets = true
|
||||||
credential_cache = "~/.local/share/dosh/credentials"
|
credential_cache = "~/.local/share/dosh/credentials"
|
||||||
|
auth_preference = "native,ssh"
|
||||||
|
trust_on_first_use = false
|
||||||
|
native_auth_timeout_ms = 700
|
||||||
|
known_hosts = "~/.config/dosh/known_hosts"
|
||||||
|
identity_files = ["~/.ssh/id_ed25519"]
|
||||||
|
use_ssh_agent = true
|
||||||
|
forward_agent = false
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -2,13 +2,16 @@ use anyhow::{Context, Result};
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use dosh::auth::{build_bootstrap, encode_bootstrap, load_or_create_server_secret};
|
use dosh::auth::{build_bootstrap, encode_bootstrap, load_or_create_server_secret};
|
||||||
use dosh::config::load_server_config;
|
use dosh::config::load_server_config;
|
||||||
|
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[arg(long, default_value_t = 1)]
|
#[arg(long, default_value_t = 1)]
|
||||||
protocol: u8,
|
protocol: u8,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
nonce: String,
|
nonce: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
host_key: bool,
|
||||||
#[arg(long, default_value = "default")]
|
#[arg(long, default_value = "default")]
|
||||||
session: String,
|
session: String,
|
||||||
#[arg(long, default_value = "read-write")]
|
#[arg(long, default_value = "read-write")]
|
||||||
@@ -25,8 +28,13 @@ fn main() -> Result<()> {
|
|||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
anyhow::ensure!(args.protocol == 1, "unsupported protocol {}", args.protocol);
|
anyhow::ensure!(args.protocol == 1, "unsupported protocol {}", args.protocol);
|
||||||
let config = load_server_config(None)?;
|
let config = load_server_config(None)?;
|
||||||
|
if args.host_key {
|
||||||
|
let signing_key = load_or_create_host_key(&config)?;
|
||||||
|
println!("{}", host_public_key_line(&host_public_key(&signing_key)));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let secret = load_or_create_server_secret(&config)?;
|
let secret = load_or_create_server_secret(&config)?;
|
||||||
let nonce = parse_nonce(&args.nonce)?;
|
let nonce = parse_nonce(args.nonce.as_deref().context("--nonce is required")?)?;
|
||||||
let size = parse_size(&args.size)?;
|
let size = parse_size(&args.size)?;
|
||||||
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
|
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
|
||||||
let udp_host = args.udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
|
let udp_host = args.udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use dosh::auth::{
|
|||||||
};
|
};
|
||||||
use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_server_config};
|
use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_server_config};
|
||||||
use dosh::crypto;
|
use dosh::crypto;
|
||||||
|
use dosh::native::{
|
||||||
|
TrustResult, host_fingerprint, parse_host_public_key_line, remove_trusted_host, trust_host,
|
||||||
|
};
|
||||||
use dosh::protocol::{
|
use dosh::protocol::{
|
||||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||||
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
|
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
|
||||||
@@ -42,6 +45,10 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
remove: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
replace: bool,
|
||||||
|
#[arg(long)]
|
||||||
attach_only: bool,
|
attach_only: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
predict: bool,
|
predict: bool,
|
||||||
@@ -88,6 +95,9 @@ async fn main() -> Result<()> {
|
|||||||
if args.server.as_deref() == Some("import-ssh") {
|
if args.server.as_deref() == Some("import-ssh") {
|
||||||
return run_import_ssh(&config, &args.command);
|
return run_import_ssh(&config, &args.command);
|
||||||
}
|
}
|
||||||
|
if args.server.as_deref() == Some("trust") {
|
||||||
|
return run_trust_command(&config, &args);
|
||||||
|
}
|
||||||
let requested_server = args.server.unwrap_or_else(|| config.server.clone());
|
let requested_server = args.server.unwrap_or_else(|| config.server.clone());
|
||||||
let hosts = load_hosts_config(None).unwrap_or_default();
|
let hosts = load_hosts_config(None).unwrap_or_default();
|
||||||
let host = hosts
|
let host = hosts
|
||||||
@@ -278,6 +288,96 @@ async fn main() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
|
||||||
|
let host = args
|
||||||
|
.command
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| anyhow!("usage: dosh trust [--remove] [--replace] <host>"))?;
|
||||||
|
let path = expand_tilde(&config.known_hosts);
|
||||||
|
if args.remove {
|
||||||
|
if remove_trusted_host(&path, host)? {
|
||||||
|
eprintln!("dosh trust: removed {host} from {}", path.display());
|
||||||
|
} else {
|
||||||
|
eprintln!("dosh trust: {host} was not trusted in {}", path.display());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hosts = load_hosts_config(None).unwrap_or_default();
|
||||||
|
let host_config = hosts.hosts.get(host).cloned().unwrap_or_default();
|
||||||
|
let server = host_config.ssh.clone().unwrap_or_else(|| host.to_string());
|
||||||
|
let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port);
|
||||||
|
let ssh_auth_command = args
|
||||||
|
.ssh_auth_command
|
||||||
|
.clone()
|
||||||
|
.or_else(|| config.ssh_auth_command.clone())
|
||||||
|
.unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string());
|
||||||
|
let public_key = fetch_host_key_over_ssh(
|
||||||
|
&server,
|
||||||
|
ssh_port,
|
||||||
|
&ssh_auth_command,
|
||||||
|
args.ssh_key.as_deref(),
|
||||||
|
args.ssh_known_hosts.as_deref(),
|
||||||
|
args.ssh_control_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
match trust_host(&path, host, &public_key, "ssh", args.replace)? {
|
||||||
|
TrustResult::AlreadyTrusted => {
|
||||||
|
eprintln!(
|
||||||
|
"dosh trust: {host} already trusted ({})",
|
||||||
|
host_fingerprint(&public_key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TrustResult::Trusted => {
|
||||||
|
eprintln!(
|
||||||
|
"dosh trust: trusted {host} {} in {}",
|
||||||
|
host_fingerprint(&public_key),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_host_key_over_ssh(
|
||||||
|
server: &str,
|
||||||
|
ssh_port: Option<u16>,
|
||||||
|
ssh_auth_command: &str,
|
||||||
|
ssh_key: Option<&Path>,
|
||||||
|
ssh_known_hosts: Option<&Path>,
|
||||||
|
ssh_control_path: Option<&Path>,
|
||||||
|
) -> Result<dosh::native::HostPublicKey> {
|
||||||
|
let mut command = Command::new("ssh");
|
||||||
|
if let Some(ssh_port) = ssh_port {
|
||||||
|
command.arg("-p").arg(ssh_port.to_string());
|
||||||
|
}
|
||||||
|
command.arg("-T");
|
||||||
|
if let Some(key) = ssh_key {
|
||||||
|
command.arg("-i").arg(key);
|
||||||
|
}
|
||||||
|
if let Some(known_hosts) = ssh_known_hosts {
|
||||||
|
command
|
||||||
|
.arg("-o")
|
||||||
|
.arg(format!("UserKnownHostsFile={}", known_hosts.display()));
|
||||||
|
}
|
||||||
|
if let Some(control_path) = ssh_control_path {
|
||||||
|
command.arg("-S").arg(control_path);
|
||||||
|
}
|
||||||
|
let output = command
|
||||||
|
.arg(server)
|
||||||
|
.arg(ssh_auth_command)
|
||||||
|
.arg("--host-key")
|
||||||
|
.output()
|
||||||
|
.context("fetch Dosh host key over SSH")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Dosh host-key fetch failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let raw = String::from_utf8(output.stdout)?;
|
||||||
|
parse_host_public_key_line(&raw)
|
||||||
|
}
|
||||||
|
|
||||||
fn startup_command(args: &[String]) -> Option<Vec<u8>> {
|
fn startup_command(args: &[String]) -> Option<Vec<u8>> {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
+11
-2
@@ -6,6 +6,7 @@ use dosh::auth::{
|
|||||||
};
|
};
|
||||||
use dosh::config::{ServerConfig, load_server_config};
|
use dosh::config::{ServerConfig, load_server_config};
|
||||||
use dosh::crypto;
|
use dosh::crypto;
|
||||||
|
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
|
||||||
use dosh::protocol::{
|
use dosh::protocol::{
|
||||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||||
PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody,
|
PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody,
|
||||||
@@ -36,7 +37,9 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 1)]
|
#[arg(long, default_value_t = 1)]
|
||||||
protocol: u8,
|
protocol: u8,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
nonce: String,
|
nonce: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
host_key: bool,
|
||||||
#[arg(long, default_value = "default")]
|
#[arg(long, default_value = "default")]
|
||||||
session: String,
|
session: String,
|
||||||
#[arg(long, default_value = "read-write")]
|
#[arg(long, default_value = "read-write")]
|
||||||
@@ -58,6 +61,7 @@ async fn main() -> Result<()> {
|
|||||||
Command::Auth {
|
Command::Auth {
|
||||||
protocol,
|
protocol,
|
||||||
nonce,
|
nonce,
|
||||||
|
host_key,
|
||||||
session,
|
session,
|
||||||
mode,
|
mode,
|
||||||
size,
|
size,
|
||||||
@@ -66,8 +70,13 @@ async fn main() -> Result<()> {
|
|||||||
} => {
|
} => {
|
||||||
anyhow::ensure!(protocol == 1, "unsupported protocol {protocol}");
|
anyhow::ensure!(protocol == 1, "unsupported protocol {protocol}");
|
||||||
let config = load_server_config(None)?;
|
let config = load_server_config(None)?;
|
||||||
|
if host_key {
|
||||||
|
let signing_key = load_or_create_host_key(&config)?;
|
||||||
|
println!("{}", host_public_key_line(&host_public_key(&signing_key)));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let secret = load_or_create_server_secret(&config)?;
|
let secret = load_or_create_server_secret(&config)?;
|
||||||
let nonce = parse_nonce(&nonce)?;
|
let nonce = parse_nonce(nonce.as_deref().context("--nonce is required")?)?;
|
||||||
let size = parse_size(&size)?;
|
let size = parse_size(&size)?;
|
||||||
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
|
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
|
||||||
let udp_host = udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
|
let udp_host = udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ pub struct ServerConfig {
|
|||||||
pub shell: String,
|
pub shell: String,
|
||||||
pub sessions_dir: String,
|
pub sessions_dir: String,
|
||||||
pub secret_path: String,
|
pub secret_path: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub native_auth: bool,
|
||||||
|
#[serde(default = "default_host_key")]
|
||||||
|
pub host_key: String,
|
||||||
|
#[serde(default = "default_authorized_keys")]
|
||||||
|
pub authorized_keys: Vec<String>,
|
||||||
|
#[serde(default = "default_native_auth_rate_limit_per_minute")]
|
||||||
|
pub native_auth_rate_limit_per_minute: u32,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub allow_tcp_forwarding: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_remote_forwarding: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_agent_forwarding: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -39,6 +53,13 @@ impl Default for ServerConfig {
|
|||||||
shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
|
shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
|
||||||
sessions_dir: "~/.local/share/dosh/sessions".to_string(),
|
sessions_dir: "~/.local/share/dosh/sessions".to_string(),
|
||||||
secret_path: "~/.config/dosh/secret".to_string(),
|
secret_path: "~/.config/dosh/secret".to_string(),
|
||||||
|
native_auth: true,
|
||||||
|
host_key: default_host_key(),
|
||||||
|
authorized_keys: default_authorized_keys(),
|
||||||
|
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
|
||||||
|
allow_tcp_forwarding: true,
|
||||||
|
allow_remote_forwarding: false,
|
||||||
|
allow_agent_forwarding: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +80,20 @@ pub struct ClientConfig {
|
|||||||
pub predict: bool,
|
pub predict: bool,
|
||||||
pub cache_attach_tickets: bool,
|
pub cache_attach_tickets: bool,
|
||||||
pub credential_cache: String,
|
pub credential_cache: String,
|
||||||
|
#[serde(default = "default_auth_preference")]
|
||||||
|
pub auth_preference: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trust_on_first_use: bool,
|
||||||
|
#[serde(default = "default_native_auth_timeout_ms")]
|
||||||
|
pub native_auth_timeout_ms: u64,
|
||||||
|
#[serde(default = "default_known_hosts")]
|
||||||
|
pub known_hosts: String,
|
||||||
|
#[serde(default = "default_identity_files")]
|
||||||
|
pub identity_files: Vec<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub use_ssh_agent: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub forward_agent: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -93,10 +128,52 @@ impl Default for ClientConfig {
|
|||||||
predict: false,
|
predict: false,
|
||||||
cache_attach_tickets: true,
|
cache_attach_tickets: true,
|
||||||
credential_cache: "~/.local/share/dosh/credentials".to_string(),
|
credential_cache: "~/.local/share/dosh/credentials".to_string(),
|
||||||
|
auth_preference: default_auth_preference(),
|
||||||
|
trust_on_first_use: false,
|
||||||
|
native_auth_timeout_ms: default_native_auth_timeout_ms(),
|
||||||
|
known_hosts: default_known_hosts(),
|
||||||
|
identity_files: default_identity_files(),
|
||||||
|
use_ssh_agent: true,
|
||||||
|
forward_agent: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_host_key() -> String {
|
||||||
|
"~/.config/dosh/host_key".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_authorized_keys() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"~/.ssh/authorized_keys".to_string(),
|
||||||
|
"~/.config/dosh/authorized_keys".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_native_auth_rate_limit_per_minute() -> u32 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auth_preference() -> String {
|
||||||
|
"native,ssh".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_native_auth_timeout_ms() -> u64 {
|
||||||
|
700
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_known_hosts() -> String {
|
||||||
|
"~/.config/dosh/known_hosts".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_identity_files() -> Vec<String> {
|
||||||
|
vec!["~/.ssh/id_ed25519".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_server_config(path: Option<PathBuf>) -> Result<ServerConfig> {
|
pub fn load_server_config(path: Option<PathBuf>) -> Result<ServerConfig> {
|
||||||
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml"));
|
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml"));
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
|
pub mod native;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
|
|||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
use crate::auth::now_secs;
|
||||||
|
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 ed25519_dalek::{SigningKey, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct HostPublicKey {
|
||||||
|
pub algorithm: String,
|
||||||
|
pub key: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct KnownHost {
|
||||||
|
pub host: String,
|
||||||
|
pub algorithm: String,
|
||||||
|
pub key: [u8; 32],
|
||||||
|
pub first_seen: u64,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
|
||||||
|
let path = expand_tilde(&config.host_key);
|
||||||
|
if path.exists() {
|
||||||
|
let raw = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
let decoded = if raw.len() == 32 {
|
||||||
|
raw
|
||||||
|
} else {
|
||||||
|
URL_SAFE_NO_PAD
|
||||||
|
.decode(String::from_utf8_lossy(&raw).trim())
|
||||||
|
.context("decode Dosh host key")?
|
||||||
|
};
|
||||||
|
anyhow::ensure!(decoded.len() == 32, "Dosh host key must be 32 bytes");
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
bytes.copy_from_slice(&decoded);
|
||||||
|
return Ok(SigningKey::from_bytes(&bytes));
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let bytes = crypto::random_32();
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&path)
|
||||||
|
.with_context(|| format!("create {}", path.display()))?;
|
||||||
|
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
|
||||||
|
file.write_all(b"\n")?;
|
||||||
|
Ok(SigningKey::from_bytes(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_public_key(signing_key: &SigningKey) -> HostPublicKey {
|
||||||
|
let verifying = VerifyingKey::from(signing_key);
|
||||||
|
HostPublicKey {
|
||||||
|
algorithm: HOST_KEY_ALGORITHM.to_string(),
|
||||||
|
key: verifying.to_bytes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_public_key_line(key: &HostPublicKey) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {} {}",
|
||||||
|
key.algorithm,
|
||||||
|
URL_SAFE_NO_PAD.encode(key.key),
|
||||||
|
host_fingerprint(key)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_host_public_key_line(raw: &str) -> Result<HostPublicKey> {
|
||||||
|
let mut parts = raw.split_whitespace();
|
||||||
|
let algorithm = parts.next().context("missing Dosh host key algorithm")?;
|
||||||
|
if algorithm != HOST_KEY_ALGORITHM {
|
||||||
|
bail!("unsupported Dosh host key algorithm {algorithm}");
|
||||||
|
}
|
||||||
|
let key_raw = parts.next().context("missing Dosh host public key")?;
|
||||||
|
let decoded = URL_SAFE_NO_PAD
|
||||||
|
.decode(key_raw)
|
||||||
|
.context("decode Dosh host public key")?;
|
||||||
|
anyhow::ensure!(decoded.len() == 32, "Dosh host public key must be 32 bytes");
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&decoded);
|
||||||
|
Ok(HostPublicKey {
|
||||||
|
algorithm: algorithm.to_string(),
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_fingerprint(key: &HostPublicKey) -> String {
|
||||||
|
format!(
|
||||||
|
"SHA256:{}",
|
||||||
|
URL_SAFE_NO_PAD.encode(crypto::sha256(&key.key))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {} {} first-seen={} source={}",
|
||||||
|
host,
|
||||||
|
key.algorithm,
|
||||||
|
URL_SAFE_NO_PAD.encode(key.key),
|
||||||
|
first_seen,
|
||||||
|
source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_known_hosts(raw: &str) -> Result<Vec<KnownHost>> {
|
||||||
|
raw.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, line)| {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parse_known_host_line(index + 1, line))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_known_host_line(line_number: usize, line: &str) -> Result<KnownHost> {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let host = parts
|
||||||
|
.next()
|
||||||
|
.with_context(|| format!("known_hosts:{line_number}: missing host"))?;
|
||||||
|
let algorithm = parts
|
||||||
|
.next()
|
||||||
|
.with_context(|| format!("known_hosts:{line_number}: missing algorithm"))?;
|
||||||
|
if algorithm != HOST_KEY_ALGORITHM {
|
||||||
|
bail!("known_hosts:{line_number}: unsupported algorithm {algorithm}");
|
||||||
|
}
|
||||||
|
let key_raw = parts
|
||||||
|
.next()
|
||||||
|
.with_context(|| format!("known_hosts:{line_number}: missing key"))?;
|
||||||
|
let decoded = URL_SAFE_NO_PAD
|
||||||
|
.decode(key_raw)
|
||||||
|
.with_context(|| format!("known_hosts:{line_number}: decode key"))?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
decoded.len() == 32,
|
||||||
|
"known_hosts:{line_number}: host key must be 32 bytes"
|
||||||
|
);
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&decoded);
|
||||||
|
let mut first_seen = 0;
|
||||||
|
let mut source = "unknown".to_string();
|
||||||
|
for part in parts {
|
||||||
|
if let Some(value) = part.strip_prefix("first-seen=") {
|
||||||
|
first_seen = value.parse().unwrap_or(0);
|
||||||
|
} else if let Some(value) = part.strip_prefix("source=") {
|
||||||
|
source = value.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(KnownHost {
|
||||||
|
host: host.to_string(),
|
||||||
|
algorithm: algorithm.to_string(),
|
||||||
|
key,
|
||||||
|
first_seen,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trust_host(
|
||||||
|
path: &Path,
|
||||||
|
host: &str,
|
||||||
|
key: &HostPublicKey,
|
||||||
|
source: &str,
|
||||||
|
replace: bool,
|
||||||
|
) -> Result<TrustResult> {
|
||||||
|
let raw = if path.exists() {
|
||||||
|
fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let mut entries = parse_known_hosts(&raw)?;
|
||||||
|
if let Some(existing) = entries.iter().find(|entry| entry.host == host) {
|
||||||
|
if existing.key == key.key && existing.algorithm == key.algorithm {
|
||||||
|
return Ok(TrustResult::AlreadyTrusted);
|
||||||
|
}
|
||||||
|
if !replace {
|
||||||
|
bail!(
|
||||||
|
"Dosh host key mismatch for {host}: existing {}, new {}",
|
||||||
|
host_fingerprint(&HostPublicKey {
|
||||||
|
algorithm: existing.algorithm.clone(),
|
||||||
|
key: existing.key,
|
||||||
|
}),
|
||||||
|
host_fingerprint(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
entries.retain(|entry| entry.host != host);
|
||||||
|
}
|
||||||
|
entries.push(KnownHost {
|
||||||
|
host: host.to_string(),
|
||||||
|
algorithm: key.algorithm.clone(),
|
||||||
|
key: key.key,
|
||||||
|
first_seen: now_secs()?,
|
||||||
|
source: source.to_string(),
|
||||||
|
});
|
||||||
|
write_known_hosts(path, &entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_trusted_host(path: &Path, host: &str) -> Result<bool> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
let mut entries = parse_known_hosts(&raw)?;
|
||||||
|
let before = entries.len();
|
||||||
|
entries.retain(|entry| entry.host != host);
|
||||||
|
if entries.len() == before {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
write_known_host_entries(path, &entries)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_known_hosts(path: &Path, entries: &[KnownHost]) -> Result<TrustResult> {
|
||||||
|
write_known_host_entries(path, entries)?;
|
||||||
|
Ok(TrustResult::Trusted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
for entry in entries {
|
||||||
|
out.push_str(&known_host_line(
|
||||||
|
&entry.host,
|
||||||
|
&HostPublicKey {
|
||||||
|
algorithm: entry.algorithm.clone(),
|
||||||
|
key: entry.key,
|
||||||
|
},
|
||||||
|
&entry.source,
|
||||||
|
entry.first_seen,
|
||||||
|
));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("write {}", path.display()))?;
|
||||||
|
file.write_all(out.as_bytes())
|
||||||
|
.with_context(|| format!("write {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TrustResult {
|
||||||
|
AlreadyTrusted,
|
||||||
|
Trusted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_public_key_line_round_trips() {
|
||||||
|
let signing = SigningKey::from_bytes(&[7u8; 32]);
|
||||||
|
let public = host_public_key(&signing);
|
||||||
|
let line = host_public_key_line(&public);
|
||||||
|
let parsed = parse_host_public_key_line(&line).unwrap();
|
||||||
|
assert_eq!(parsed, public);
|
||||||
|
assert!(host_fingerprint(&public).starts_with("SHA256:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_host_trust_rejects_mismatch_without_replace() {
|
||||||
|
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!(
|
||||||
|
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
|
||||||
|
TrustResult::Trusted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
|
||||||
|
TrustResult::AlreadyTrusted
|
||||||
|
);
|
||||||
|
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
trust_host(&path, "palav", &second, "ssh", true).unwrap(),
|
||||||
|
TrustResult::Trusted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user