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, pub supported_user_key_algorithms: Vec, pub cached_host_key_fingerprint: Option, pub attach_ticket_envelope: Option>, #[serde(default)] pub requested_env: Vec, } #[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, pub host_signature: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NativeUserAuth { pub public_key_algorithm: String, pub public_key: Vec, pub signature: Vec, pub requested_forwardings: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ForwardingRequest { pub kind: ForwardingKind, pub bind_host: Option, pub listen_port: u16, pub target_host: Option, pub target_port: Option, } #[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, pub attach_ticket_psk: [u8; 32], pub initial_seq: u64, pub snapshot: Vec, pub policy_flags: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthorizedKey { pub algorithm: String, pub key: Vec, pub options: AuthorizedKeyOptions, pub comment: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AuthorizedKeyOptions { pub from: Option, pub force_command: Option, pub restricted: bool, pub no_port_forwarding: bool, pub permitopen: Vec, pub unsupported: Vec, } pub fn load_or_create_host_key(config: &ServerConfig) -> Result { 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 { 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> { 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> { 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, ) -> Result { 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, ) -> Result { 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> { 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::::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 { 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, ) -> Result { 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 { 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::::new(public); SignatureVerifier::verify(&key, transcript, &signature) .context("verify RSA-SHA256 signature") } "rsa-sha2-512" => { let key = rsa::pkcs1v15::VerifyingKey::::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, ) -> Result { 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 { load_ed25519_identity_with_passphrase(path, None) } pub fn load_ed25519_identity_with_passphrase( path: &Path, passphrase: Option<&str>, ) -> Result { 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 { load_native_identity_with_passphrase(path, None) } pub fn load_native_identity_with_passphrase( path: &Path, passphrase: Option<&str>, ) -> Result { 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, ) -> 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> { 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> { 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 { 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 { 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 { 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> { 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 { line.split_whitespace().map(ToString::to_string).collect() } fn parse_authorized_key_options(raw: &str) -> Result { 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> { 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 { 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 { 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::() .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 { 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 { 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, 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> { 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 { 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 { 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 { 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 { 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 { 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(), } } }