Enforce authorized_keys from restrictions
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 22:07:04 -04:00
parent 0575cf678f
commit af5e9f7956
4 changed files with 221 additions and 21 deletions
+14 -3
View File
@@ -406,13 +406,23 @@ async fn handle_native_user_auth(
if pending.client.requested_mode == "doctor" { if pending.client.requested_mode == "doctor" {
let check = { let check = {
let locked = state.lock().expect("server state poisoned"); 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, &locked.config,
&pending.client, &pending.client,
&pending.server, &pending.server,
&req.auth, &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 { NativeAuthCheckOkBody {
requested_user: pending.client.requested_user.clone(), requested_user: pending.client.requested_user.clone(),
native_auth_enabled: locked.config.native_auth, native_auth_enabled: locked.config.native_auth,
@@ -453,6 +463,7 @@ async fn handle_native_user_auth(
&pending.client, &pending.client,
&pending.server, &pending.server,
&req.auth, &req.auth,
Some(peer.ip()),
) )
.context("verify native user auth") .context("verify native user auth")
.and_then(|_| { .and_then(|_| {
@@ -539,7 +550,7 @@ async fn handle_native_user_auth(
) = match attached { ) = match attached {
Ok(attached) => attached, Ok(attached) => attached,
Err(err) => { 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; .await;
} }
}; };
+176 -12
View File
@@ -8,8 +8,10 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::net::IpAddr;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::Path; use std::path::Path;
use std::str::FromStr;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519"; pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
@@ -290,6 +292,7 @@ pub fn verify_native_user_auth(
server: &NativeServerHello, server: &NativeServerHello,
auth: &NativeUserAuth, auth: &NativeUserAuth,
authorized_keys: &[AuthorizedKey], authorized_keys: &[AuthorizedKey],
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> { ) -> Result<AuthorizedKey> {
anyhow::ensure!( anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519", auth.public_key_algorithm == "ssh-ed25519",
@@ -308,7 +311,7 @@ pub fn verify_native_user_auth(
.iter() .iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key) .find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?; .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]; let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key); public_key.copy_from_slice(&auth.public_key);
@@ -328,9 +331,10 @@ pub fn verify_native_user_auth_from_config(
client: &NativeClientHello, client: &NativeClientHello,
server: &NativeServerHello, server: &NativeServerHello,
auth: &NativeUserAuth, auth: &NativeUserAuth,
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> { ) -> Result<AuthorizedKey> {
let authorized_keys = load_authorized_keys(&config.authorized_keys)?; 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]) { pub fn generate_native_ephemeral() -> (StaticSecret, [u8; 32]) {
@@ -385,13 +389,25 @@ pub fn load_ed25519_identity_with_passphrase(
} }
impl AuthorizedKey { impl AuthorizedKey {
fn ensure_native_allowed(&self, auth: &NativeUserAuth) -> Result<()> { fn ensure_native_allowed(
&self,
auth: &NativeUserAuth,
source_ip: Option<IpAddr>,
) -> Result<()> {
if !self.options.unsupported.is_empty() { if !self.options.unsupported.is_empty() {
bail!( bail!(
"authorized key has unsupported options: {}", "authorized key has unsupported options: {}",
self.options.unsupported.join(",") 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() { if self.options.force_command.is_some() {
bail!("authorized key command= is not supported for native Dosh terminal login"); 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) .unwrap_or(raw)
} }
fn source_matches_from(source: IpAddr, raw: &str) -> Result<bool> {
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<bool> {
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::<u8>()
.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<bool> {
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]> { pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
let mut cursor = blob; let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?; let key_type = read_ssh_string(&mut cursor)?;
@@ -884,11 +991,11 @@ mod tests {
let authorized = let authorized =
parse_authorized_keys(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap(); 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 = let removed =
parse_authorized_keys(&ssh_ed25519_authorized_key_line(&other_signing)).unwrap(); 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] #[test]
@@ -904,7 +1011,7 @@ mod tests {
let mut tampered = client.clone(); let mut tampered = client.clone();
tampered.requested_session = "other".to_string(); 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] #[test]
@@ -921,7 +1028,60 @@ mod tests {
); );
let authorized = parse_authorized_keys(&raw).unwrap(); 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] #[test]
@@ -1028,21 +1188,21 @@ mod tests {
ssh_ed25519_authorized_key_line(&user_signing) ssh_ed25519_authorized_key_line(&user_signing)
)) ))
.unwrap(); .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!( let permitted = parse_authorized_keys(&format!(
"permitopen=\"127.0.0.1:80\" {}", "permitopen=\"127.0.0.1:80\" {}",
ssh_ed25519_authorized_key_line(&user_signing) ssh_ed25519_authorized_key_line(&user_signing)
)) ))
.unwrap(); .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!( let denied = parse_authorized_keys(&format!(
"permitopen=\"127.0.0.1:81\" {}", "permitopen=\"127.0.0.1:81\" {}",
ssh_ed25519_authorized_key_line(&user_signing) ssh_ed25519_authorized_key_line(&user_signing)
)) ))
.unwrap(); .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 { let dynamic = ForwardingRequest {
kind: ForwardingKind::Dynamic, kind: ForwardingKind::Dynamic,
@@ -1052,8 +1212,12 @@ mod tests {
target_port: None, target_port: None,
}; };
let dynamic_auth = sign_user_auth(&user_signing, &client, &server, vec![dynamic]).unwrap(); 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!(
assert!(verify_native_user_auth(&client, &server, &dynamic_auth, &permitted).is_err()); 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 { fn test_client_hello() -> NativeClientHello {
+1 -1
View File
@@ -217,7 +217,7 @@ mod tests {
); );
let authorized = parse_authorized_keys(&authorized_line).unwrap(); 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<u8>, signing: SigningKey) { fn fake_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey) {
+30 -5
View File
@@ -223,6 +223,14 @@ fn start_echo_server() -> u16 {
} }
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) { 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"); let ssh_dir = dir.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap(); fs::create_dir_all(&ssh_dir).unwrap();
let signing = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]); 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 private
.write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF) .write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF)
.unwrap(); .unwrap();
fs::write( let public = private.public_key().to_openssh().unwrap();
dir.path().join("authorized_keys"), let line = if options.is_empty() {
format!("{}\n", private.public_key().to_openssh().unwrap()), public
) } else {
.unwrap(); 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 config = load_server_config(Some(config_path.to_path_buf())).unwrap();
let host_signing = load_or_create_host_key(&config).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}"); 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] #[test]
fn native_attach_only_smoke_uses_ssh_agent() { fn native_attach_only_smoke_uses_ssh_agent() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();