Files
dosh/src/native.rs
T
DuProcess 742477bf6e
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
Add Windows client packaging and recovery tools
2026-06-20 19:40:33 -04:00

1657 lines
59 KiB
Rust

use crate::auth::now_secs;
use crate::config::{ServerConfig, expand_tilde};
use crate::crypto;
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs;
use std::io::Write;
use std::net::IpAddr;
#[cfg(unix)]
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";
pub const NATIVE_PROTOCOL_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostPublicKey {
pub algorithm: String,
pub key: [u8; 32],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnownHost {
pub host: String,
pub algorithm: String,
pub key: [u8; 32],
pub first_seen: u64,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeClientHello {
pub protocol_version: u8,
pub client_random: [u8; 32],
pub client_ephemeral_public: [u8; 32],
pub requested_host: String,
pub requested_user: String,
pub requested_session: String,
pub requested_mode: String,
pub terminal_size: (u16, u16),
pub supported_aead: Vec<String>,
pub supported_user_key_algorithms: Vec<String>,
pub cached_host_key_fingerprint: Option<String>,
pub attach_ticket_envelope: Option<Vec<u8>>,
#[serde(default)]
pub requested_env: Vec<EnvVar>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvVar {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeServerHello {
pub protocol_version: u8,
pub server_random: [u8; 32],
pub server_ephemeral_public: [u8; 32],
pub host_key: HostPublicKey,
pub chosen_aead: String,
pub server_key_epoch: u64,
pub auth_challenge: [u8; 32],
pub rate_limit_remaining: Option<u32>,
pub host_signature: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeUserAuth {
pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub signature: Vec<u8>,
pub requested_forwardings: Vec<ForwardingRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForwardingRequest {
pub kind: ForwardingKind,
pub bind_host: Option<String>,
pub listen_port: u16,
pub target_host: Option<String>,
pub target_port: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ForwardingKind {
Local,
Remote,
Dynamic,
/// SSH-agent forwarding: the client opts in and, if the server policy allows
/// (`allow_agent_forwarding`), the server exports a proxy unix socket as
/// `SSH_AUTH_SOCK` in the remote session and tunnels each connection back to
/// the client's local agent over a Dosh stream. Added at the end of the enum
/// so bincode discriminants for the existing variants are unchanged.
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeAuthOk {
pub client_id: [u8; 16],
pub session: String,
pub mode: String,
pub session_key: [u8; 32],
pub session_key_id: [u8; 16],
pub attach_ticket: Vec<u8>,
pub attach_ticket_psk: [u8; 32],
pub initial_seq: u64,
pub snapshot: Vec<u8>,
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() {
let raw = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let decoded = if raw.len() == 32 {
raw
} else {
URL_SAFE_NO_PAD
.decode(String::from_utf8_lossy(&raw).trim())
.context("decode Dosh host key")?
};
anyhow::ensure!(decoded.len() == 32, "Dosh host key must be 32 bytes");
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&decoded);
return Ok(SigningKey::from_bytes(&bytes));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let bytes = crypto::random_32();
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(&path)
.with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
file.write_all(b"\n")?;
Ok(SigningKey::from_bytes(&bytes))
}
pub fn host_public_key(signing_key: &SigningKey) -> HostPublicKey {
let verifying = VerifyingKey::from(signing_key);
HostPublicKey {
algorithm: HOST_KEY_ALGORITHM.to_string(),
key: verifying.to_bytes(),
}
}
pub fn host_public_key_line(key: &HostPublicKey) -> String {
format!(
"{} {} {}",
key.algorithm,
URL_SAFE_NO_PAD.encode(key.key),
host_fingerprint(key)
)
}
pub fn parse_host_public_key_line(raw: &str) -> Result<HostPublicKey> {
let mut parts = raw.split_whitespace();
let algorithm = parts.next().context("missing Dosh host key algorithm")?;
if algorithm != HOST_KEY_ALGORITHM {
bail!("unsupported Dosh host key algorithm {algorithm}");
}
let key_raw = parts.next().context("missing Dosh host public key")?;
let decoded = URL_SAFE_NO_PAD
.decode(key_raw)
.context("decode Dosh host public key")?;
anyhow::ensure!(decoded.len() == 32, "Dosh host public key must be 32 bytes");
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
Ok(HostPublicKey {
algorithm: algorithm.to_string(),
key,
})
}
pub fn host_fingerprint(key: &HostPublicKey) -> String {
format!(
"SHA256:{}",
URL_SAFE_NO_PAD.encode(crypto::sha256(&key.key))
)
}
pub fn sign_host_transcript(signing_key: &SigningKey, transcript: &[u8]) -> [u8; 64] {
signing_key.sign(transcript).to_bytes()
}
pub fn verify_host_signature(
host_key: &HostPublicKey,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
if host_key.algorithm != HOST_KEY_ALGORITHM {
bail!("unsupported Dosh host key algorithm {}", host_key.algorithm);
}
let verifying_key =
VerifyingKey::from_bytes(&host_key.key).context("parse Dosh host verifying key")?;
anyhow::ensure!(
signature.len() == 64,
"Dosh host signature must be 64 bytes"
);
let mut signature_bytes = [0u8; 64];
signature_bytes.copy_from_slice(signature);
let signature = Signature::from_bytes(&signature_bytes);
verifying_key
.verify(transcript, &signature)
.context("verify Dosh host signature")
}
pub fn server_hello_transcript(
client: &NativeClientHello,
server: &NativeServerHello,
) -> Result<Vec<u8>> {
let mut unsigned = server.clone();
unsigned.host_signature.clear();
let mut out = b"dosh/native/server-hello/v1".to_vec();
out.extend(bincode::serialize(client)?);
out.extend(bincode::serialize(&unsigned)?);
Ok(out)
}
pub fn sign_server_hello(
signing_key: &SigningKey,
client: &NativeClientHello,
server: &mut NativeServerHello,
) -> Result<()> {
server.host_signature.clear();
let transcript = server_hello_transcript(client, server)?;
server.host_signature = sign_host_transcript(signing_key, &transcript).to_vec();
Ok(())
}
/// Error raised when a native handshake peer speaks a different protocol version.
///
/// Carries the peer's advertised version so callers can render an actionable,
/// named message ("upgrade dosh") rather than letting a mismatch surface as an
/// opaque decrypt failure or a silent timeout. The `Display` text deliberately
/// embeds [`crate::protocol::VERSION_MISMATCH_REASON`] so the server's reject and
/// the client's local error read the same way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolVersionMismatch {
pub local: u8,
pub remote: u8,
pub peer: &'static str,
}
impl std::fmt::Display for ProtocolVersionMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({} speaks native protocol v{}, this build speaks v{})",
crate::protocol::VERSION_MISMATCH_REASON,
self.peer,
self.remote,
self.local,
)
}
}
impl std::error::Error for ProtocolVersionMismatch {}
/// Verify a peer-advertised native protocol version against this build.
///
/// `peer` names whose version was wrong ("client" or "server") for the error
/// message. Returns a typed [`ProtocolVersionMismatch`] so the caller can both
/// match on it and print an actionable, upgrade-oriented message.
pub fn check_native_protocol_version(version: u8, peer: &'static str) -> Result<()> {
if version != NATIVE_PROTOCOL_VERSION {
return Err(ProtocolVersionMismatch {
local: NATIVE_PROTOCOL_VERSION,
remote: version,
peer,
}
.into());
}
Ok(())
}
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
check_native_protocol_version(client.protocol_version, "client")?;
check_native_protocol_version(server.protocol_version, "server")?;
let transcript = server_hello_transcript(client, server)?;
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 sign_user_auth_with_private_key(
private_key: &ssh_key::PrivateKey,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
let public_key = private_key.public_key();
let key_algorithm = public_key.algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&key_algorithm),
"unsupported native identity key algorithm {key_algorithm}"
);
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
} else {
public_key_blob
};
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
let mut auth = NativeUserAuth {
public_key_algorithm: signature_algorithm.to_string(),
public_key: public_key_for_auth,
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = if key_algorithm == "ssh-rsa" {
sign_rsa_sha512_private_key(private_key, &transcript)?
} else {
let signature = SignatureSigner::try_sign(private_key, &transcript)
.context("sign native user auth with OpenSSH private key")?;
anyhow::ensure!(
signature.algorithm().as_str() == auth.public_key_algorithm,
"private key produced signature algorithm {}, expected {}",
signature.algorithm().as_str(),
auth.public_key_algorithm
);
signature.as_bytes().to_vec()
};
Ok(auth)
}
fn sign_rsa_sha512_private_key(
private_key: &ssh_key::PrivateKey,
transcript: &[u8],
) -> Result<Vec<u8>> {
let rsa_keypair = private_key
.key_data()
.rsa()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
Ok(signature.to_vec())
}
fn rsa_private_key_from_ssh_keypair(
keypair: &ssh_key::private::RsaKeypair,
) -> Result<rsa::RsaPrivateKey> {
let private = rsa::RsaPrivateKey::from_components(
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
vec![
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
],
)
.context("convert OpenSSH RSA private key")?;
anyhow::ensure!(
private.size().saturating_mul(8) >= 2048,
"OpenSSH RSA identity is smaller than 2048 bits"
);
Ok(private)
}
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
match key_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"ssh-rsa" => Ok("rsa-sha2-512"),
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
}
}
pub fn verify_native_user_auth(
client: &NativeClientHello,
server: &NativeServerHello,
auth: &NativeUserAuth,
authorized_keys: &[AuthorizedKey],
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> {
anyhow::ensure!(
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}",
auth.public_key_algorithm
);
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let transcript = user_auth_transcript(client, server, auth)?;
verify_native_user_signature(auth, &transcript)?;
Ok(authorized.clone())
}
pub fn supported_user_key_algorithms() -> Vec<String> {
vec![
"ssh-ed25519".to_string(),
"ecdsa-sha2-nistp256".to_string(),
"rsa-sha2-512".to_string(),
"rsa-sha2-256".to_string(),
]
}
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
}
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
)
}
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
match signature_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
}
}
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
match auth.public_key_algorithm.as_str() {
"ssh-ed25519" => {
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 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);
verifying_key
.verify(transcript, &signature)
.context("verify native user signature")?;
}
"ecdsa-sha2-nistp256" => {
let public_key = ssh_public_key_from_blob(&auth.public_key)
.context("parse native user SSH public key")?;
let algorithm =
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
format!("parse signature algorithm {}", auth.public_key_algorithm)
})?;
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
.context("parse native user SSH signature")?;
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
.context("verify native user SSH signature")?;
}
"rsa-sha2-512" | "rsa-sha2-256" => {
verify_rsa_sha2_signature(
&auth.public_key,
&auth.public_key_algorithm,
transcript,
&auth.signature,
)?;
}
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
}
Ok(())
}
fn verify_rsa_sha2_signature(
public_key_blob: &[u8],
signature_algorithm: &str,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
let public = rsa::RsaPublicKey::new(
rsa::BigUint::from_bytes_be(n),
rsa::BigUint::from_bytes_be(e),
)
.context("parse RSA public key")?;
let signature =
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
match signature_algorithm {
"rsa-sha2-256" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA256 signature")
}
"rsa-sha2-512" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA512 signature")
}
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
}
}
pub fn verify_native_user_auth_from_config(
config: &ServerConfig,
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, source_ip)
}
pub fn generate_native_ephemeral() -> (StaticSecret, [u8; 32]) {
let secret = StaticSecret::random();
let public = X25519PublicKey::from(&secret).to_bytes();
(secret, public)
}
pub fn derive_native_session_key(
local_secret: &StaticSecret,
remote_public: [u8; 32],
client: &NativeClientHello,
server: &NativeServerHello,
) -> Result<[u8; 32]> {
let remote_public = X25519PublicKey::from(remote_public);
let shared = local_secret.diffie_hellman(&remote_public);
anyhow::ensure!(
shared.was_contributory(),
"native key exchange produced non-contributory shared secret"
);
let mut salt = b"dosh/native/session-key/v1".to_vec();
salt.extend(crypto::sha256(&bincode::serialize(client)?));
salt.extend(crypto::sha256(&bincode::serialize(server)?));
crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1")
}
/// Derive a rotated transport key for transport rekey (spec §11 / §9).
///
/// The rotated key is derived **independently of the handshake traffic keys**:
/// the input keying material is the current epoch's key (which both peers already
/// hold) mixed with fresh, server-generated `rekey_material` (32 bytes of CSPRNG
/// output, delivered confidentially inside an AEAD `Rekey` packet sealed under
/// the current key). The new `epoch` and the previous epoch's `session_key_id`
/// salt the derivation so each epoch's key is unique. It never re-derives from
/// the handshake DH output, satisfying the spec requirement that "rotated session
/// keys must be derived independently ... from fresh randomness" and "must not
/// reuse handshake traffic keys."
///
/// Both peers run this identically — the client never needs the server's
/// long-term secret, only the fresh `rekey_material` it receives in the `Rekey`
/// packet plus the current key it already shares.
pub fn derive_rekey_session_key(
current_key: &[u8; 32],
rekey_material: &[u8; 32],
previous_session_key_id: &[u8; 16],
epoch: u64,
) -> Result<[u8; 32]> {
let mut ikm = Vec::with_capacity(64);
ikm.extend_from_slice(current_key);
ikm.extend_from_slice(rekey_material);
let mut salt = b"dosh/native/rekey/v1".to_vec();
salt.extend_from_slice(previous_session_key_id);
salt.extend_from_slice(&epoch.to_be_bytes());
crypto::hkdf32(&ikm, &salt, b"dosh/native/rekey/chacha20poly1305/v1")
}
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
load_ed25519_identity_with_passphrase(path, None)
}
pub fn load_ed25519_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<SigningKey> {
let private_key = ssh_key::PrivateKey::read_openssh_file(path)
.with_context(|| format!("read OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let ed25519 = private_key
.key_data()
.ed25519()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is not ssh-ed25519", path.display()))?;
ed25519_dalek::SigningKey::try_from(ed25519)
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
}
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
load_native_identity_with_passphrase(path, None)
}
pub fn load_native_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<ssh_key::PrivateKey> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let algorithm = private_key.public_key().algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&algorithm),
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
path.display()
);
Ok(private_key)
}
impl AuthorizedKey {
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");
}
if self.options.no_port_forwarding && !auth.requested_forwardings.is_empty() {
bail!("authorized key forbids port forwarding");
}
if !self.options.permitopen.is_empty() {
for forwarding in &auth.requested_forwardings {
match forwarding.kind {
ForwardingKind::Local => {
if 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}");
}
}
}
ForwardingKind::Remote | ForwardingKind::Dynamic => {
bail!(
"authorized key permitopen= cannot authorize remote or dynamic forwarding"
);
}
// Agent forwarding does not open a host:port, so permitopen
// (which restricts which targets may be opened) does not apply;
// it is gated separately by the server's allow_agent_forwarding.
ForwardingKind::Agent => {}
}
}
}
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 is_supported_user_key_algorithm(&fields[0]) {
(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"))?;
let decoded = STANDARD
.decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
anyhow::ensure!(
is_supported_user_key_algorithm(algorithm),
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
validate_supported_public_key_blob(algorithm, &decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
let key = if algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
} else {
decoded
};
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 validate_supported_public_key_blob(algorithm: &str, blob: &[u8]) -> Result<()> {
let blob_algorithm = ssh_public_key_blob_algorithm(blob)?;
anyhow::ensure!(
blob_algorithm == algorithm,
"key blob type {blob_algorithm} does not match authorized_keys type {algorithm}"
);
match algorithm {
"ssh-ed25519" => {
parse_ssh_ed25519_public_blob(blob)?;
}
"ecdsa-sha2-nistp256" | "ssh-rsa" => {
let _ = ssh_public_key_from_blob(blob)?;
}
_ => bail!("unsupported key type {algorithm}"),
}
Ok(())
}
fn ssh_public_key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
fn ssh_public_key_from_blob(blob: &[u8]) -> Result<ssh_key::PublicKey> {
let algorithm = ssh_public_key_blob_algorithm(blob)?;
let encoded = STANDARD.encode(blob);
ssh_key::PublicKey::from_openssh(&format!("{algorithm} {encoded}"))
.with_context(|| format!("parse OpenSSH public key blob {algorithm}"))
}
fn ssh_public_blob_from_public_key(public_key: &ssh_key::PublicKey) -> Result<Vec<u8>> {
let line = public_key
.to_openssh()
.context("encode OpenSSH public key")?;
let mut fields = line.split_whitespace();
let _algorithm = fields
.next()
.context("encoded public key missing algorithm")?;
let encoded = fields.next().context("encoded public key missing blob")?;
STANDARD
.decode(encoded)
.context("decode encoded OpenSSH public key blob")
}
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 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)?;
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 parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
let e = read_ssh_mpint(&mut cursor)?;
let n = read_ssh_mpint(&mut cursor)?;
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
Ok((e, n))
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519");
write_ssh_string(&mut out, public_key);
out
}
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let raw = read_ssh_string(cursor)?;
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
Ok(raw)
}
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)
}
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);
}
#[cfg(test)]
fn ssh_ed25519_authorized_key_line(signing_key: &SigningKey) -> String {
let verifying = VerifyingKey::from(signing_key);
let blob = ssh_ed25519_public_blob(&verifying.to_bytes());
format!("ssh-ed25519 {} test-key", STANDARD.encode(blob))
}
pub fn known_host_line(host: &str, key: &HostPublicKey, source: &str, first_seen: u64) -> String {
format!(
"{} {} {} first-seen={} source={}",
host,
key.algorithm,
URL_SAFE_NO_PAD.encode(key.key),
first_seen,
source
)
}
pub fn parse_known_hosts(raw: &str) -> Result<Vec<KnownHost>> {
raw.lines()
.enumerate()
.filter_map(|(index, line)| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
None
} else {
Some(parse_known_host_line(index + 1, line))
}
})
.collect()
}
pub fn verify_known_host(path: &Path, host: &str, key: &HostPublicKey) -> Result<KnownHostStatus> {
if !path.exists() {
return Ok(KnownHostStatus::Unknown);
}
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let entries = parse_known_hosts(&raw)?;
let Some(existing) = entries.iter().find(|entry| entry.host == host) else {
return Ok(KnownHostStatus::Unknown);
};
if existing.algorithm == key.algorithm && existing.key == key.key {
Ok(KnownHostStatus::Trusted)
} else {
Ok(KnownHostStatus::Mismatch {
expected: host_fingerprint(&HostPublicKey {
algorithm: existing.algorithm.clone(),
key: existing.key,
}),
actual: host_fingerprint(key),
})
}
}
fn parse_known_host_line(line_number: usize, line: &str) -> Result<KnownHost> {
let mut parts = line.split_whitespace();
let host = parts
.next()
.with_context(|| format!("known_hosts:{line_number}: missing host"))?;
let algorithm = parts
.next()
.with_context(|| format!("known_hosts:{line_number}: missing algorithm"))?;
if algorithm != HOST_KEY_ALGORITHM {
bail!("known_hosts:{line_number}: unsupported algorithm {algorithm}");
}
let key_raw = parts
.next()
.with_context(|| format!("known_hosts:{line_number}: missing key"))?;
let decoded = URL_SAFE_NO_PAD
.decode(key_raw)
.with_context(|| format!("known_hosts:{line_number}: decode key"))?;
anyhow::ensure!(
decoded.len() == 32,
"known_hosts:{line_number}: host key must be 32 bytes"
);
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
let mut first_seen = 0;
let mut source = "unknown".to_string();
for part in parts {
if let Some(value) = part.strip_prefix("first-seen=") {
first_seen = value.parse().unwrap_or(0);
} else if let Some(value) = part.strip_prefix("source=") {
source = value.to_string();
}
}
Ok(KnownHost {
host: host.to_string(),
algorithm: algorithm.to_string(),
key,
first_seen,
source,
})
}
pub fn trust_host(
path: &Path,
host: &str,
key: &HostPublicKey,
source: &str,
replace: bool,
) -> Result<TrustResult> {
let raw = if path.exists() {
fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?
} else {
String::new()
};
let mut entries = parse_known_hosts(&raw)?;
if let Some(existing) = entries.iter().find(|entry| entry.host == host) {
if existing.key == key.key && existing.algorithm == key.algorithm {
return Ok(TrustResult::AlreadyTrusted);
}
if !replace {
bail!(
"Dosh host key mismatch for {host}: existing {}, new {}",
host_fingerprint(&HostPublicKey {
algorithm: existing.algorithm.clone(),
key: existing.key,
}),
host_fingerprint(key)
);
}
entries.retain(|entry| entry.host != host);
}
entries.push(KnownHost {
host: host.to_string(),
algorithm: key.algorithm.clone(),
key: key.key,
first_seen: now_secs()?,
source: source.to_string(),
});
write_known_hosts(path, &entries)
}
pub fn remove_trusted_host(path: &Path, host: &str) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut entries = parse_known_hosts(&raw)?;
let before = entries.len();
entries.retain(|entry| entry.host != host);
if entries.len() == before {
return Ok(false);
}
write_known_host_entries(path, &entries)?;
Ok(true)
}
fn write_known_hosts(path: &Path, entries: &[KnownHost]) -> Result<TrustResult> {
write_known_host_entries(path, entries)?;
Ok(TrustResult::Trusted)
}
fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let mut out = String::new();
for entry in entries {
out.push_str(&known_host_line(
&entry.host,
&HostPublicKey {
algorithm: entry.algorithm.clone(),
key: entry.key,
},
&entry.source,
entry.first_seen,
));
out.push('\n');
}
let mut options = fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(path)
.with_context(|| format!("write {}", path.display()))?;
file.write_all(out.as_bytes())
.with_context(|| format!("write {}", path.display()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustResult {
AlreadyTrusted,
Trusted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KnownHostStatus {
Unknown,
Trusted,
Mismatch { expected: String, actual: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_public_key_line_round_trips() {
let signing = SigningKey::from_bytes(&[7u8; 32]);
let public = host_public_key(&signing);
let line = host_public_key_line(&public);
let parsed = parse_host_public_key_line(&line).unwrap();
assert_eq!(parsed, public);
assert!(host_fingerprint(&public).starts_with("SHA256:"));
}
#[test]
fn known_host_trust_rejects_mismatch_without_replace() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known_hosts");
let first = host_public_key(&SigningKey::from_bytes(&[1u8; 32]));
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted
);
assert_eq!(
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted
);
assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!(
trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted
);
}
#[test]
fn known_host_verification_reports_trusted_unknown_and_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known_hosts");
let first = host_public_key(&SigningKey::from_bytes(&[1u8; 32]));
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown
);
trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!(
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted
);
assert!(matches!(
verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. }
));
}
#[test]
fn server_hello_signature_round_trips() {
let signing = SigningKey::from_bytes(&[3u8; 32]);
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, None).unwrap();
let removed =
parse_authorized_keys(&ssh_ed25519_authorized_key_line(&other_signing)).unwrap();
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
}
#[test]
fn native_user_auth_accepts_ecdsa_p256_private_key() {
let mut rng = rand::rngs::OsRng;
let private = ssh_key::PrivateKey::random(
&mut rng,
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
)
.unwrap();
let host_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_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_accepts_rsa_sha2_private_key() {
let mut rng = rand::rngs::OsRng;
let private =
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
let host_signing = SigningKey::from_bytes(&[11u8; 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_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[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, None).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, 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]
fn load_ed25519_identity_reads_openssh_private_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("id_ed25519");
let signing = SigningKey::from_bytes(&[11u8; 32]);
let keypair = ssh_key::private::Ed25519Keypair::from(&signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "").unwrap();
private
.write_openssh_file(&path, ssh_key::LineEnding::LF)
.unwrap();
let loaded = load_ed25519_identity(&path).unwrap();
assert_eq!(
VerifyingKey::from(&loaded).to_bytes(),
VerifyingKey::from(&signing).to_bytes()
);
}
#[test]
fn load_ed25519_identity_decrypts_encrypted_openssh_private_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("id_ed25519");
let signing = SigningKey::from_bytes(&[12u8; 32]);
let keypair = ssh_key::private::Ed25519Keypair::from(&signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "").unwrap();
let encrypted = private
.encrypt(&mut rand::rngs::OsRng, "correct horse battery staple")
.unwrap();
encrypted
.write_openssh_file(&path, ssh_key::LineEnding::LF)
.unwrap();
assert!(load_ed25519_identity(&path).is_err());
assert!(load_ed25519_identity_with_passphrase(&path, Some("wrong")).is_err());
let loaded =
load_ed25519_identity_with_passphrase(&path, Some("correct horse battery staple"))
.unwrap();
assert_eq!(
VerifyingKey::from(&loaded).to_bytes(),
VerifyingKey::from(&signing).to_bytes()
);
}
#[test]
fn native_session_key_derivation_matches_and_binds_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
let (client_secret, client_public) = generate_native_ephemeral();
let (server_secret, server_public) = generate_native_ephemeral();
let mut client = test_client_hello();
client.client_ephemeral_public = client_public;
let mut server = test_server_hello(&host_signing);
server.server_ephemeral_public = server_public;
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let client_key = derive_native_session_key(
&client_secret,
server.server_ephemeral_public,
&client,
&server,
)
.unwrap();
let server_key = derive_native_session_key(
&server_secret,
client.client_ephemeral_public,
&client,
&server,
)
.unwrap();
assert_eq!(client_key, server_key);
let mut tampered = server.clone();
tampered.auth_challenge = [99u8; 32];
let tampered_key = derive_native_session_key(
&client_secret,
tampered.server_ephemeral_public,
&client,
&tampered,
)
.unwrap();
assert_ne!(client_key, tampered_key);
}
#[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, 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, 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, None).is_err());
let dynamic = ForwardingRequest {
kind: ForwardingKind::Dynamic,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 1080,
target_host: None,
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, None).is_err()
);
assert!(
verify_native_user_auth(&client, &server, &dynamic_auth, &permitted, None).is_err()
);
}
fn test_client_hello() -> NativeClientHello {
NativeClientHello {
protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
}
}
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),
chosen_aead: "chacha20poly1305".to_string(),
server_key_epoch: 1,
auth_challenge: [6u8; 32],
rate_limit_remaining: Some(30),
host_signature: Vec::new(),
}
}
}