Files
dosh/src/config.rs
T
DuProcess f9c1973c13 Harden native protocol: version discipline, rekey, migration, rate limit, O(1) client index
Track A protocol hardening for native-v1.

1. Protocol VERSION discipline (protocol.rs, native.rs, dosh-server.rs,
   dosh-client.rs): bump protocol::VERSION to 2 and NATIVE_PROTOCOL_VERSION to 2.
   Foreign wire-version datagrams now get a clear named "protocol version
   mismatch - upgrade dosh" reject from the server instead of a silent timeout
   (peek_foreign_wire_version + VERSION_MISMATCH_REASON). The native handshake's
   plaintext protocol_version is also checked server-side before any crypto via
   check_native_protocol_version, returning a typed ProtocolVersionMismatch.

2. Transport rekey (spec section 11/9): server rotates a client's traffic key
   after rekey_after_packets OR rekey_after_secs (config knobs). Rotated keys are
   derived independently of the handshake keys from the current key plus fresh
   server CSPRNG material shipped confidentially in an AEAD Rekey packet
   (derive_rekey_session_key). The previous epoch's key is retained briefly so
   in-flight pre-rekey packets still decrypt (matched by session_key_id), and
   stale-epoch packets are ignored, not fatal. Client handles Rekey/RekeyAck.

3. Connection migration (spec section 11): every authenticated, replay-accepted
   packet now migrates client.endpoint to the new source address (input, resize,
   ping, ack, resume, stream*, rekey-ack), not just resume. Ping now verifies its
   AEAD tag before acting so migration cannot be spoofed.

4. Native auth rate limiting: per-source-IP token bucket
   (native_auth_rate_limit_per_minute) enforced in handle_native_client_hello
   BEFORE any X25519/Ed25519 work; over-limit hellos get a non-crypto reject.

5. Speed: replaced O(sessions x clients) per-packet linear scans with an O(1)
   HashMap<conn_id, session_name> index in ServerState, kept in sync on every
   client insert/remove (attach handlers, detach, remove_client, cleanup reap,
   session-exit drain).

New config keys (ServerConfig, with defaults): rekey_after_packets = 100000,
rekey_after_secs = 3600.

Tests: version-mismatch reject (no hang), rate-limit flood reject, rekey
round-trip end-to-end + key-derivation unit tests, source-address migration,
client-index sync + cleanup purge. fmt and full test suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:45:44 -04:00

264 lines
8.4 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub port: u16,
pub bind: String,
pub scrollback: usize,
pub auth_ttl_secs: u64,
pub attach_ticket_ttl_secs: u64,
pub allow_attach_tickets: bool,
pub client_timeout_secs: u64,
pub retransmit_window: usize,
pub default_input_mode: String,
pub prewarm_sessions: Vec<String>,
pub create_on_attach: bool,
pub shell: String,
pub sessions_dir: 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,
/// Rotate a client's transport traffic key after this many packets in the
/// current epoch (spec §11). `0` disables the packet-count trigger.
#[serde(default = "default_rekey_after_packets")]
pub rekey_after_packets: u64,
/// Rotate a client's transport traffic key after this many wall-clock seconds
/// in the current epoch (spec §11). `0` disables the time trigger.
#[serde(default = "default_rekey_after_secs")]
pub rekey_after_secs: u64,
#[serde(default = "default_true")]
pub allow_tcp_forwarding: bool,
#[serde(default)]
pub allow_remote_forwarding: bool,
#[serde(default)]
pub allow_remote_non_loopback_bind: bool,
#[serde(default)]
pub allow_agent_forwarding: bool,
#[serde(default = "default_accept_env")]
pub accept_env: Vec<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: 50000,
bind: "0.0.0.0".to_string(),
scrollback: 5000,
auth_ttl_secs: 30,
attach_ticket_ttl_secs: 3600,
allow_attach_tickets: true,
client_timeout_secs: 86400,
retransmit_window: 256,
default_input_mode: "read-write".to_string(),
prewarm_sessions: vec!["default".to_string()],
create_on_attach: true,
shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
sessions_dir: "~/.local/share/dosh/sessions".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(),
rekey_after_packets: default_rekey_after_packets(),
rekey_after_secs: default_rekey_after_secs(),
allow_tcp_forwarding: true,
allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false,
accept_env: default_accept_env(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientConfig {
pub update_repo: Option<String>,
pub update_port: Option<u16>,
pub server: String,
pub dosh_host: Option<String>,
pub ssh_auth_command: Option<String>,
pub ssh_port: Option<u16>,
pub dosh_port: u16,
pub default_session: String,
pub reconnect_timeout_secs: u64,
pub view_only: bool,
#[serde(default)]
pub predict: bool,
pub cache_attach_tickets: bool,
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,
#[serde(default = "default_send_env")]
pub send_env: Vec<String>,
#[serde(default)]
pub set_env: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostConfig {
pub ssh: Option<String>,
pub user: Option<String>,
pub dosh_host: Option<String>,
pub port: Option<u16>,
pub ssh_port: Option<u16>,
pub default_command: Option<String>,
pub predict: Option<bool>,
#[serde(default)]
pub send_env: Option<Vec<String>>,
#[serde(default)]
pub set_env: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostsConfig {
#[serde(flatten)]
pub hosts: HashMap<String, HostConfig>,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
update_repo: None,
update_port: None,
server: "user@example.com".to_string(),
dosh_host: None,
ssh_auth_command: Some("~/.local/bin/dosh-auth".to_string()),
ssh_port: None,
dosh_port: 50000,
default_session: "new".to_string(),
reconnect_timeout_secs: 5,
view_only: false,
predict: false,
cache_attach_tickets: true,
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,
send_env: default_send_env(),
set_env: HashMap::new(),
}
}
}
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_rekey_after_packets() -> u64 {
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
// keeps forward-secrecy windows small.
100_000
}
fn default_rekey_after_secs() -> u64 {
// One hour per epoch by default.
3600
}
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()]
}
fn default_send_env() -> Vec<String> {
vec![
"LANG".to_string(),
"LC_*".to_string(),
"TERM".to_string(),
"COLORTERM".to_string(),
]
}
fn default_accept_env() -> Vec<String> {
default_send_env()
}
pub fn load_server_config(path: Option<PathBuf>) -> Result<ServerConfig> {
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml"));
if !path.exists() {
return Ok(ServerConfig::default());
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}
pub fn load_client_config(path: Option<PathBuf>) -> Result<ClientConfig> {
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/client.toml"));
if !path.exists() {
return Ok(ClientConfig::default());
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}
pub fn load_hosts_config(path: Option<PathBuf>) -> Result<HostsConfig> {
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/hosts.toml"));
if !path.exists() {
return Ok(HostsConfig::default());
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(path)
}