Verify native Dosh Ed25519 user auth
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 11:38:00 -04:00
parent a72f7eacf5
commit 80c7889b46
+429 -10
View File
@@ -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());
}
}
}