diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 732bfda..d635a50 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -406,13 +406,23 @@ async fn handle_native_user_auth( if pending.client.requested_mode == "doctor" { let check = { let locked = state.lock().expect("server state poisoned"); - verify_native_user_auth_from_config( + if let Err(err) = verify_native_user_auth_from_config( &locked.config, &pending.client, &pending.server, &req.auth, + Some(peer.ip()), ) - .context("verify native user auth")?; + .context("verify native user auth") + { + return send_reject_to_client( + socket, + peer, + packet.header.conn_id, + &format!("{err:#}"), + ) + .await; + } NativeAuthCheckOkBody { requested_user: pending.client.requested_user.clone(), native_auth_enabled: locked.config.native_auth, @@ -453,6 +463,7 @@ async fn handle_native_user_auth( &pending.client, &pending.server, &req.auth, + Some(peer.ip()), ) .context("verify native user auth") .and_then(|_| { @@ -539,7 +550,7 @@ async fn handle_native_user_auth( ) = match attached { Ok(attached) => attached, Err(err) => { - return send_reject_to_client(socket, peer, packet.header.conn_id, &err.to_string()) + return send_reject_to_client(socket, peer, packet.header.conn_id, &format!("{err:#}")) .await; } }; diff --git a/src/native.rs b/src/native.rs index 8732cac..7baf056 100644 --- a/src/native.rs +++ b/src/native.rs @@ -8,8 +8,10 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; +use std::net::IpAddr; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; +use std::str::FromStr; use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519"; @@ -290,6 +292,7 @@ pub fn verify_native_user_auth( server: &NativeServerHello, auth: &NativeUserAuth, authorized_keys: &[AuthorizedKey], + source_ip: Option, ) -> Result { anyhow::ensure!( auth.public_key_algorithm == "ssh-ed25519", @@ -308,7 +311,7 @@ pub fn verify_native_user_auth( .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)?; + authorized.ensure_native_allowed(auth, source_ip)?; let mut public_key = [0u8; 32]; public_key.copy_from_slice(&auth.public_key); @@ -328,9 +331,10 @@ pub fn verify_native_user_auth_from_config( client: &NativeClientHello, server: &NativeServerHello, auth: &NativeUserAuth, + source_ip: Option, ) -> Result { let authorized_keys = load_authorized_keys(&config.authorized_keys)?; - verify_native_user_auth(client, server, auth, &authorized_keys) + verify_native_user_auth(client, server, auth, &authorized_keys, source_ip) } pub fn generate_native_ephemeral() -> (StaticSecret, [u8; 32]) { @@ -385,13 +389,25 @@ pub fn load_ed25519_identity_with_passphrase( } impl AuthorizedKey { - fn ensure_native_allowed(&self, auth: &NativeUserAuth) -> Result<()> { + fn ensure_native_allowed( + &self, + auth: &NativeUserAuth, + source_ip: Option, + ) -> Result<()> { if !self.options.unsupported.is_empty() { bail!( "authorized key has unsupported options: {}", self.options.unsupported.join(",") ); } + if let Some(from) = &self.options.from { + let Some(source_ip) = source_ip else { + bail!("authorized key from= requires source address context"); + }; + if !source_matches_from(source_ip, from)? { + bail!("authorized key from= does not permit source {source_ip}"); + } + } if self.options.force_command.is_some() { bail!("authorized key command= is not supported for native Dosh terminal login"); } @@ -560,6 +576,97 @@ fn strip_quotes(raw: &str) -> &str { .unwrap_or(raw) } +fn source_matches_from(source: IpAddr, raw: &str) -> Result { + let mut matched_positive = false; + for pattern in raw + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let (negated, pattern) = pattern + .strip_prefix('!') + .map_or((false, pattern), |stripped| (true, stripped)); + let matched = source_pattern_matches(source, pattern)?; + if negated && matched { + return Ok(false); + } + if matched { + matched_positive = true; + } + } + Ok(matched_positive) +} + +fn source_pattern_matches(source: IpAddr, pattern: &str) -> Result { + if let Some((network, prefix)) = pattern.split_once('/') { + let network = IpAddr::from_str(network) + .with_context(|| format!("parse authorized_keys from= network {pattern:?}"))?; + let prefix = prefix + .parse::() + .with_context(|| format!("parse authorized_keys from= prefix {pattern:?}"))?; + return cidr_contains(network, prefix, source); + } + if pattern.contains('*') || pattern.contains('?') { + return Ok(glob_matches(pattern, &source.to_string())); + } + match IpAddr::from_str(pattern) { + Ok(ip) => Ok(ip == source), + Err(_) => Ok(pattern == source.to_string()), + } +} + +fn cidr_contains(network: IpAddr, prefix: u8, source: IpAddr) -> Result { + match (network, source) { + (IpAddr::V4(network), IpAddr::V4(source)) => { + anyhow::ensure!(prefix <= 32, "IPv4 from= prefix must be <= 32"); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Ok((u32::from(network) & mask) == (u32::from(source) & mask)) + } + (IpAddr::V6(network), IpAddr::V6(source)) => { + anyhow::ensure!(prefix <= 128, "IPv6 from= prefix must be <= 128"); + let mask = if prefix == 0 { + 0 + } else { + u128::MAX << (128 - prefix) + }; + Ok((u128::from(network) & mask) == (u128::from(source) & mask)) + } + _ => Ok(false), + } +} + +fn glob_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let (mut p, mut v) = (0usize, 0usize); + let mut star = None; + let mut star_value = 0usize; + while v < value.len() { + if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) { + p += 1; + v += 1; + } else if p < pattern.len() && pattern[p] == b'*' { + star = Some(p); + p += 1; + star_value = v; + } else if let Some(star_pos) = star { + p = star_pos + 1; + star_value += 1; + v = star_value; + } else { + return false; + } + } + while p < pattern.len() && pattern[p] == b'*' { + p += 1; + } + p == pattern.len() +} + pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> { let mut cursor = blob; let key_type = read_ssh_string(&mut cursor)?; @@ -884,11 +991,11 @@ mod tests { let authorized = parse_authorized_keys(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap(); - verify_native_user_auth(&client, &server, &auth, &authorized).unwrap(); + verify_native_user_auth(&client, &server, &auth, &authorized, None).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()); + assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err()); } #[test] @@ -904,7 +1011,7 @@ mod tests { let mut tampered = client.clone(); tampered.requested_session = "other".to_string(); - assert!(verify_native_user_auth(&tampered, &server, &auth, &authorized).is_err()); + assert!(verify_native_user_auth(&tampered, &server, &auth, &authorized, None).is_err()); } #[test] @@ -921,7 +1028,60 @@ mod tests { ); let authorized = parse_authorized_keys(&raw).unwrap(); - assert!(verify_native_user_auth(&client, &server, &auth, &authorized).is_err()); + assert!(verify_native_user_auth(&client, &server, &auth, &authorized, None).is_err()); + } + + #[test] + fn native_user_auth_enforces_from_source_patterns() { + 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(&format!( + "from=\"127.0.0.1,192.168.1.0/24,!192.168.1.66\" {}", + ssh_ed25519_authorized_key_line(&user_signing) + )) + .unwrap(); + + verify_native_user_auth( + &client, + &server, + &auth, + &authorized, + Some("127.0.0.1".parse().unwrap()), + ) + .unwrap(); + verify_native_user_auth( + &client, + &server, + &auth, + &authorized, + Some("192.168.1.42".parse().unwrap()), + ) + .unwrap(); + assert!( + verify_native_user_auth( + &client, + &server, + &auth, + &authorized, + Some("192.168.1.66".parse().unwrap()), + ) + .is_err() + ); + assert!( + verify_native_user_auth( + &client, + &server, + &auth, + &authorized, + Some("10.0.0.1".parse().unwrap()), + ) + .is_err() + ); + assert!(verify_native_user_auth(&client, &server, &auth, &authorized, None).is_err()); } #[test] @@ -1028,21 +1188,21 @@ mod tests { ssh_ed25519_authorized_key_line(&user_signing) )) .unwrap(); - assert!(verify_native_user_auth(&client, &server, &auth, &no_forwarding).is_err()); + assert!(verify_native_user_auth(&client, &server, &auth, &no_forwarding, None).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(); + verify_native_user_auth(&client, &server, &auth, &permitted, None).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()); + assert!(verify_native_user_auth(&client, &server, &auth, &denied, None).is_err()); let dynamic = ForwardingRequest { kind: ForwardingKind::Dynamic, @@ -1052,8 +1212,12 @@ mod tests { target_port: None, }; let dynamic_auth = sign_user_auth(&user_signing, &client, &server, vec![dynamic]).unwrap(); - assert!(verify_native_user_auth(&client, &server, &dynamic_auth, &no_forwarding).is_err()); - assert!(verify_native_user_auth(&client, &server, &dynamic_auth, &permitted).is_err()); + assert!( + verify_native_user_auth(&client, &server, &dynamic_auth, &no_forwarding, None).is_err() + ); + assert!( + verify_native_user_auth(&client, &server, &dynamic_auth, &permitted, None).is_err() + ); } fn test_client_hello() -> NativeClientHello { diff --git a/src/ssh_agent.rs b/src/ssh_agent.rs index 2275fa2..ba1ef01 100644 --- a/src/ssh_agent.rs +++ b/src/ssh_agent.rs @@ -217,7 +217,7 @@ mod tests { ); let authorized = parse_authorized_keys(&authorized_line).unwrap(); - verify_native_user_auth(&client, &server, &auth, &authorized).unwrap(); + verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap(); } fn fake_agent(listener: UnixListener, key_blob: Vec, signing: SigningKey) { diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index 07ccdc6..5c9b04d 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -223,6 +223,14 @@ fn start_echo_server() -> u16 { } fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) { + write_native_client_auth_with_options(dir, config_path, ""); +} + +fn write_native_client_auth_with_options( + dir: &tempfile::TempDir, + config_path: &std::path::Path, + options: &str, +) { let ssh_dir = dir.path().join(".ssh"); fs::create_dir_all(&ssh_dir).unwrap(); let signing = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]); @@ -232,11 +240,13 @@ fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Pa private .write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF) .unwrap(); - fs::write( - dir.path().join("authorized_keys"), - format!("{}\n", private.public_key().to_openssh().unwrap()), - ) - .unwrap(); + let public = private.public_key().to_openssh().unwrap(); + let line = if options.is_empty() { + public + } else { + format!("{options} {public}") + }; + fs::write(dir.path().join("authorized_keys"), format!("{line}\n")).unwrap(); let config = load_server_config(Some(config_path.to_path_buf())).unwrap(); let host_signing = load_or_create_host_key(&config).unwrap(); @@ -556,6 +566,21 @@ fn native_attach_only_smoke() { assert!(stderr.contains("terminal_ready"), "stderr={stderr}"); } +#[test] +fn native_attach_enforces_authorized_keys_from_option() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + write_native_client_auth_with_options(&dir, &config, "from=\"10.99.0.0/16\""); + let mut server = start_server(&dir, &config); + let output = native_attach_once(&dir, port); + let _ = server.kill(); + let _ = server.wait(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "stderr={stderr}"); + assert!(stderr.contains("from="), "stderr={stderr}"); +} + #[test] fn native_attach_only_smoke_uses_ssh_agent() { let dir = tempfile::tempdir().unwrap();