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
1657 lines
59 KiB
Rust
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(),
|
|
}
|
|
}
|
|
}
|