Verify native Dosh Ed25519 user auth
This commit is contained in:
+429
-10
@@ -3,7 +3,7 @@ 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 base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -95,6 +95,24 @@ pub struct NativeAuthOk {
|
||||
pub policy_flags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizedKey {
|
||||
pub algorithm: String,
|
||||
pub key: Vec<u8>,
|
||||
pub options: AuthorizedKeyOptions,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AuthorizedKeyOptions {
|
||||
pub from: Option<String>,
|
||||
pub force_command: Option<String>,
|
||||
pub restricted: bool,
|
||||
pub no_port_forwarding: bool,
|
||||
pub permitopen: Vec<String>,
|
||||
pub unsupported: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
|
||||
let path = expand_tilde(&config.host_key);
|
||||
if path.exists() {
|
||||
@@ -233,6 +251,296 @@ pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHell
|
||||
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
|
||||
}
|
||||
|
||||
pub fn user_auth_transcript(
|
||||
client: &NativeClientHello,
|
||||
server: &NativeServerHello,
|
||||
auth: &NativeUserAuth,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut unsigned = auth.clone();
|
||||
unsigned.signature.clear();
|
||||
let mut out = b"dosh/native/user-auth/v1".to_vec();
|
||||
out.extend(bincode::serialize(client)?);
|
||||
out.extend(bincode::serialize(server)?);
|
||||
out.extend(bincode::serialize(&unsigned)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn sign_user_auth(
|
||||
signing_key: &SigningKey,
|
||||
client: &NativeClientHello,
|
||||
server: &NativeServerHello,
|
||||
requested_forwardings: Vec<ForwardingRequest>,
|
||||
) -> Result<NativeUserAuth> {
|
||||
let verifying = VerifyingKey::from(signing_key);
|
||||
let mut auth = NativeUserAuth {
|
||||
public_key_algorithm: "ssh-ed25519".to_string(),
|
||||
public_key: verifying.to_bytes().to_vec(),
|
||||
signature: Vec::new(),
|
||||
requested_forwardings,
|
||||
};
|
||||
let transcript = user_auth_transcript(client, server, &auth)?;
|
||||
auth.signature = signing_key.sign(&transcript).to_bytes().to_vec();
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
pub fn verify_native_user_auth(
|
||||
client: &NativeClientHello,
|
||||
server: &NativeServerHello,
|
||||
auth: &NativeUserAuth,
|
||||
authorized_keys: &[AuthorizedKey],
|
||||
) -> Result<AuthorizedKey> {
|
||||
anyhow::ensure!(
|
||||
auth.public_key_algorithm == "ssh-ed25519",
|
||||
"unsupported native user key algorithm {}",
|
||||
auth.public_key_algorithm
|
||||
);
|
||||
anyhow::ensure!(
|
||||
auth.public_key.len() == 32,
|
||||
"ssh-ed25519 public key must be 32 bytes"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
auth.signature.len() == 64,
|
||||
"ssh-ed25519 signature must be 64 bytes"
|
||||
);
|
||||
let authorized = authorized_keys
|
||||
.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)?;
|
||||
|
||||
let mut public_key = [0u8; 32];
|
||||
public_key.copy_from_slice(&auth.public_key);
|
||||
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
|
||||
let mut signature = [0u8; 64];
|
||||
signature.copy_from_slice(&auth.signature);
|
||||
let signature = Signature::from_bytes(&signature);
|
||||
let transcript = user_auth_transcript(client, server, auth)?;
|
||||
verifying_key
|
||||
.verify(&transcript, &signature)
|
||||
.context("verify native user signature")?;
|
||||
Ok(authorized.clone())
|
||||
}
|
||||
|
||||
pub fn verify_native_user_auth_from_config(
|
||||
config: &ServerConfig,
|
||||
client: &NativeClientHello,
|
||||
server: &NativeServerHello,
|
||||
auth: &NativeUserAuth,
|
||||
) -> Result<AuthorizedKey> {
|
||||
let authorized_keys = load_authorized_keys(&config.authorized_keys)?;
|
||||
verify_native_user_auth(client, server, auth, &authorized_keys)
|
||||
}
|
||||
|
||||
impl AuthorizedKey {
|
||||
fn ensure_native_allowed(&self, auth: &NativeUserAuth) -> Result<()> {
|
||||
if !self.options.unsupported.is_empty() {
|
||||
bail!(
|
||||
"authorized key has unsupported options: {}",
|
||||
self.options.unsupported.join(",")
|
||||
);
|
||||
}
|
||||
if self.options.force_command.is_some() {
|
||||
bail!("authorized key command= is not supported for native Dosh terminal login");
|
||||
}
|
||||
if self.options.no_port_forwarding
|
||||
&& auth
|
||||
.requested_forwardings
|
||||
.iter()
|
||||
.any(|forwarding| !matches!(forwarding.kind, ForwardingKind::Dynamic))
|
||||
{
|
||||
bail!("authorized key forbids port forwarding");
|
||||
}
|
||||
if !self.options.permitopen.is_empty() {
|
||||
for forwarding in &auth.requested_forwardings {
|
||||
if matches!(forwarding.kind, ForwardingKind::Local)
|
||||
&& let (Some(host), Some(port)) =
|
||||
(forwarding.target_host.as_ref(), forwarding.target_port)
|
||||
{
|
||||
let target = format!("{host}:{port}");
|
||||
if !self
|
||||
.options
|
||||
.permitopen
|
||||
.iter()
|
||||
.any(|permit| permit == &target)
|
||||
{
|
||||
bail!("authorized key does not permit opening {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_authorized_keys(paths: &[String]) -> Result<Vec<AuthorizedKey>> {
|
||||
let mut out = Vec::new();
|
||||
for path in paths {
|
||||
let path = expand_tilde(path);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
out.extend(
|
||||
parse_authorized_keys(&raw).with_context(|| format!("parse {}", path.display()))?,
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn parse_authorized_keys(raw: &str) -> Result<Vec<AuthorizedKey>> {
|
||||
raw.lines()
|
||||
.enumerate()
|
||||
.filter_map(|(index, line)| {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
None
|
||||
} else {
|
||||
Some(parse_authorized_key_line(index + 1, line))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<AuthorizedKey> {
|
||||
let fields = split_authorized_key_fields(line);
|
||||
anyhow::ensure!(
|
||||
fields.len() >= 2,
|
||||
"authorized_keys:{line_number}: expected key fields"
|
||||
);
|
||||
let (options, key_type_index) = if fields[0].starts_with("ssh-") {
|
||||
(AuthorizedKeyOptions::default(), 0)
|
||||
} else {
|
||||
(parse_authorized_key_options(&fields[0])?, 1)
|
||||
};
|
||||
let algorithm = fields
|
||||
.get(key_type_index)
|
||||
.with_context(|| format!("authorized_keys:{line_number}: missing key type"))?;
|
||||
let key_blob = fields
|
||||
.get(key_type_index + 1)
|
||||
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
|
||||
anyhow::ensure!(
|
||||
algorithm == "ssh-ed25519",
|
||||
"authorized_keys:{line_number}: unsupported key type {algorithm}"
|
||||
);
|
||||
let decoded = STANDARD
|
||||
.decode(key_blob)
|
||||
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
|
||||
let key = parse_ssh_ed25519_public_blob(&decoded)
|
||||
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
|
||||
let comment = if fields.len() > key_type_index + 2 {
|
||||
Some(fields[key_type_index + 2..].join(" "))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AuthorizedKey {
|
||||
algorithm: algorithm.to_string(),
|
||||
key: key.to_vec(),
|
||||
options,
|
||||
comment,
|
||||
})
|
||||
}
|
||||
|
||||
fn split_authorized_key_fields(line: &str) -> Vec<String> {
|
||||
line.split_whitespace().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn parse_authorized_key_options(raw: &str) -> Result<AuthorizedKeyOptions> {
|
||||
let mut options = AuthorizedKeyOptions::default();
|
||||
for option in split_options(raw)? {
|
||||
if option == "restrict" {
|
||||
options.restricted = true;
|
||||
} else if option == "no-port-forwarding" {
|
||||
options.no_port_forwarding = true;
|
||||
} else if let Some(value) = option.strip_prefix("from=") {
|
||||
options.from = Some(strip_quotes(value).to_string());
|
||||
} else if let Some(value) = option.strip_prefix("command=") {
|
||||
options.force_command = Some(strip_quotes(value).to_string());
|
||||
} else if let Some(value) = option.strip_prefix("permitopen=") {
|
||||
options.permitopen.push(strip_quotes(value).to_string());
|
||||
} else {
|
||||
options.unsupported.push(option);
|
||||
}
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn split_options(raw: &str) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
for ch in raw.chars() {
|
||||
if escaped {
|
||||
current.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' && quoted {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '"' {
|
||||
quoted = !quoted;
|
||||
current.push(ch);
|
||||
continue;
|
||||
}
|
||||
if ch == ',' && !quoted {
|
||||
out.push(current);
|
||||
current = String::new();
|
||||
continue;
|
||||
}
|
||||
current.push(ch);
|
||||
}
|
||||
anyhow::ensure!(!quoted, "unterminated authorized_keys option quote");
|
||||
if !current.is_empty() {
|
||||
out.push(current);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn strip_quotes(raw: &str) -> &str {
|
||||
raw.strip_prefix('"')
|
||||
.and_then(|value| value.strip_suffix('"'))
|
||||
.unwrap_or(raw)
|
||||
}
|
||||
|
||||
fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
|
||||
let mut cursor = blob;
|
||||
let key_type = read_ssh_string(&mut cursor)?;
|
||||
anyhow::ensure!(key_type == b"ssh-ed25519", "key blob type mismatch");
|
||||
let key = read_ssh_string(&mut cursor)?;
|
||||
anyhow::ensure!(key.len() == 32, "ssh-ed25519 key must be 32 bytes");
|
||||
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-ed25519 key blob");
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(key);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
||||
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
|
||||
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
|
||||
*cursor = &cursor[4..];
|
||||
anyhow::ensure!(cursor.len() >= len, "truncated SSH string body");
|
||||
let (value, rest) = cursor.split_at(len);
|
||||
*cursor = rest;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ssh_ed25519_authorized_key_line(signing_key: &SigningKey) -> String {
|
||||
let verifying = VerifyingKey::from(signing_key);
|
||||
let mut blob = Vec::new();
|
||||
write_ssh_string(&mut blob, b"ssh-ed25519");
|
||||
write_ssh_string(&mut blob, &verifying.to_bytes());
|
||||
format!("ssh-ed25519 {} test-key", STANDARD.encode(blob))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
|
||||
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
|
||||
pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String {
|
||||
format!(
|
||||
"{} {} {} first-seen={} source={}",
|
||||
@@ -482,7 +790,119 @@ mod tests {
|
||||
#[test]
|
||||
fn server_hello_signature_round_trips() {
|
||||
let signing = SigningKey::from_bytes(&[3u8; 32]);
|
||||
let client = NativeClientHello {
|
||||
let client = test_client_hello();
|
||||
let mut server = test_server_hello(&signing);
|
||||
sign_server_hello(&signing, &client, &mut server).unwrap();
|
||||
verify_server_hello(&client, &server).unwrap();
|
||||
server.auth_challenge = [7u8; 32];
|
||||
assert!(verify_server_hello(&client, &server).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorized_keys_parses_ed25519() {
|
||||
let signing = SigningKey::from_bytes(&[8u8; 32]);
|
||||
let raw = ssh_ed25519_authorized_key_line(&signing);
|
||||
let parsed = parse_authorized_keys(&raw).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].algorithm, "ssh-ed25519");
|
||||
assert_eq!(
|
||||
parsed[0].key,
|
||||
VerifyingKey::from(&signing).to_bytes().to_vec()
|
||||
);
|
||||
assert_eq!(parsed[0].comment.as_deref(), Some("test-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_user_auth_accepts_authorized_key_and_rejects_removed_key() {
|
||||
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
|
||||
let user_signing = SigningKey::from_bytes(&[9u8; 32]);
|
||||
let other_signing = SigningKey::from_bytes(&[10u8; 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(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap();
|
||||
|
||||
verify_native_user_auth(&client, &server, &auth, &authorized).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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_user_auth_rejects_tampered_transcript() {
|
||||
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(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap();
|
||||
let mut tampered = client.clone();
|
||||
tampered.requested_session = "other".to_string();
|
||||
|
||||
assert!(verify_native_user_auth(&tampered, &server, &auth, &authorized).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_user_auth_fails_closed_on_unsupported_authorized_key_options() {
|
||||
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 raw = format!(
|
||||
"no-agent-forwarding {}",
|
||||
ssh_ed25519_authorized_key_line(&user_signing)
|
||||
);
|
||||
let authorized = parse_authorized_keys(&raw).unwrap();
|
||||
|
||||
assert!(verify_native_user_auth(&client, &server, &auth, &authorized).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_user_auth_enforces_no_port_forwarding_and_permitopen() {
|
||||
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 forward = ForwardingRequest {
|
||||
kind: ForwardingKind::Local,
|
||||
bind_host: None,
|
||||
listen_port: 8080,
|
||||
target_host: Some("127.0.0.1".to_string()),
|
||||
target_port: Some(80),
|
||||
};
|
||||
let auth = sign_user_auth(&user_signing, &client, &server, vec![forward]).unwrap();
|
||||
let no_forwarding = parse_authorized_keys(&format!(
|
||||
"no-port-forwarding {}",
|
||||
ssh_ed25519_authorized_key_line(&user_signing)
|
||||
))
|
||||
.unwrap();
|
||||
assert!(verify_native_user_auth(&client, &server, &auth, &no_forwarding).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();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
fn test_client_hello() -> NativeClientHello {
|
||||
NativeClientHello {
|
||||
protocol_version: NATIVE_PROTOCOL_VERSION,
|
||||
client_random: [1u8; 32],
|
||||
client_ephemeral_public: [2u8; 32],
|
||||
@@ -495,21 +915,20 @@ mod tests {
|
||||
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
|
||||
cached_host_key_fingerprint: None,
|
||||
attach_ticket_envelope: None,
|
||||
};
|
||||
let mut server = NativeServerHello {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_server_hello(signing: &SigningKey) -> NativeServerHello {
|
||||
NativeServerHello {
|
||||
protocol_version: NATIVE_PROTOCOL_VERSION,
|
||||
server_random: [4u8; 32],
|
||||
server_ephemeral_public: [5u8; 32],
|
||||
host_key: host_public_key(&signing),
|
||||
host_key: host_public_key(signing),
|
||||
chosen_aead: "chacha20poly1305".to_string(),
|
||||
server_key_epoch: 1,
|
||||
auth_challenge: [6u8; 32],
|
||||
rate_limit_remaining: Some(30),
|
||||
host_signature: Vec::new(),
|
||||
};
|
||||
sign_server_hello(&signing, &client, &mut server).unwrap();
|
||||
verify_server_hello(&client, &server).unwrap();
|
||||
server.auth_challenge = [7u8; 32];
|
||||
assert!(verify_server_hello(&client, &server).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user