Enforce authorized_keys from restrictions
This commit is contained in:
+14
-3
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
+176
-12
@@ -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<IpAddr>,
|
||||
) -> Result<AuthorizedKey> {
|
||||
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<IpAddr>,
|
||||
) -> Result<AuthorizedKey> {
|
||||
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<IpAddr>,
|
||||
) -> 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<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]> {
|
||||
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 {
|
||||
|
||||
+1
-1
@@ -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<u8>, signing: SigningKey) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user