Expand native auth and benchmark gates

This commit is contained in:
DuProcess
2026-06-18 09:29:06 -04:00
parent 2835da76b0
commit 25d9a6aefa
13 changed files with 1036 additions and 178 deletions
+21 -18
View File
@@ -10,9 +10,10 @@ use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_ser
use dosh::crypto;
use dosh::native::{
EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, TrustResult,
derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_ed25519_identity,
load_ed25519_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host,
sign_user_auth, trust_host, verify_known_host, verify_server_hello,
derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_native_identity,
load_native_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host,
sign_user_auth_with_private_key, supported_user_key_algorithms, trust_host, verify_known_host,
verify_server_hello,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
@@ -1486,7 +1487,7 @@ async fn try_native_auth(
requested_mode: mode.to_string(),
terminal_size: (cols, rows),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
supported_user_key_algorithms: supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env,
@@ -1642,7 +1643,7 @@ async fn try_native_auth_check(
requested_mode: "doctor".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
supported_user_key_algorithms: supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
@@ -1766,7 +1767,9 @@ fn sign_native_user_auth(
}
match load_first_native_identity(config, cli_identity, ssh_config) {
Ok(identity) => sign_user_auth(&identity, hello, server_hello, requested_forwardings),
Ok(identity) => {
sign_user_auth_with_private_key(&identity, hello, server_hello, requested_forwardings)
}
Err(err) => {
errors.push(format!("identity files: {err:#}"));
Err(anyhow!(
@@ -1781,7 +1784,7 @@ fn load_first_native_identity(
config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>,
ssh_config: &SshConfig,
) -> Result<ed25519_dalek::SigningKey> {
) -> Result<ssh_key::PrivateKey> {
load_first_native_identity_with_prompt(
config,
cli_identity,
@@ -1795,7 +1798,7 @@ fn load_first_native_identity_with_prompt<F>(
cli_identity: Option<&Path>,
ssh_config: &SshConfig,
mut prompt: F,
) -> Result<ed25519_dalek::SigningKey>
) -> Result<ssh_key::PrivateKey>
where
F: FnMut(&Path) -> Result<String>,
{
@@ -1817,11 +1820,11 @@ where
if !path.exists() {
continue;
}
match load_ed25519_identity(&path) {
match load_native_identity(&path) {
Ok(identity) => return Ok(identity),
Err(err) if err.to_string().contains(" is encrypted") => {
match prompt(&path).and_then(|passphrase| {
load_ed25519_identity_with_passphrase(&path, Some(&passphrase))
load_native_identity_with_passphrase(&path, Some(&passphrase))
}) {
Ok(identity) => return Ok(identity),
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
@@ -1831,10 +1834,10 @@ where
}
}
if errors.is_empty() {
Err(anyhow!("no usable ssh-ed25519 identity found"))
Err(anyhow!("no usable native identity found"))
} else {
Err(anyhow!(
"no usable ssh-ed25519 identity found: {}",
"no usable native identity found: {}",
errors.join("; ")
))
}
@@ -4222,8 +4225,8 @@ mod tests {
.unwrap();
assert_eq!(
VerifyingKey::from(&loaded).to_bytes(),
VerifyingKey::from(&signing).to_bytes()
loaded.public_key().key_data().ed25519().unwrap().as_ref(),
VerifyingKey::from(&signing).as_bytes()
);
}
@@ -4243,7 +4246,7 @@ mod tests {
)
.unwrap_err();
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
assert!(err.to_string().contains("no usable native identity"));
}
#[test]
@@ -4263,7 +4266,7 @@ mod tests {
})
.unwrap_err();
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
assert!(err.to_string().contains("no usable native identity"));
}
#[test]
@@ -4285,8 +4288,8 @@ mod tests {
.unwrap();
assert_eq!(
VerifyingKey::from(&loaded).to_bytes(),
VerifyingKey::from(&signing).to_bytes()
loaded.public_key().key_data().ed25519().unwrap().as_ref(),
VerifyingKey::from(&signing).as_bytes()
);
}
+94 -2
View File
@@ -878,9 +878,11 @@ async fn handle_native_client_hello(
.hello
.supported_user_key_algorithms
.iter()
.any(|algorithm| algorithm == "ssh-ed25519")
.any(|algorithm| dosh::native::is_supported_user_signature_algorithm(algorithm))
{
Err(anyhow!("native auth requires ssh-ed25519"))
Err(anyhow!(
"native auth requires a supported user key algorithm"
))
} else {
let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
if req.hello.requested_user != current_user {
@@ -3266,6 +3268,96 @@ mod tests {
assert_eq!(data.bytes, b"hello");
}
#[tokio::test]
async fn blocked_stream_data_does_not_block_terminal_frames() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [8u8; 16];
let session_key = [10u8; 32];
let receiver = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let sender = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let endpoint = receiver.local_addr().unwrap();
state.sessions.insert(
"test".to_string(),
Session {
pty: None,
parser: vt100::Parser::new(24, 80, 100),
clients: HashMap::from([(
client_id,
ClientState {
endpoint,
mode: "read-write".to_string(),
session_key,
last_acked: 0,
replay: ReplayWindow::default(),
send_seq: 1,
cols: 80,
rows: 24,
last_seen: Instant::now(),
pending: VecDeque::new(),
allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(),
opened_streams: HashSet::from([42]),
stream_send_credit: HashMap::from([(42, 0)]),
stream_pending_data: HashMap::new(),
epoch: 0,
epoch_started: Instant::now(),
epoch_packets: 0,
previous_session_key: None,
},
)]),
output_seq: 0,
recent: VecDeque::new(),
empty_since: None,
holder_control: None,
persistent: false,
bytes_since_persist: 0,
last_persisted_seq: 0,
},
);
state.client_index.insert(client_id, "test".to_string());
let state = Arc::new(Mutex::new(state));
queue_or_send_stream_data_to_client(
&state,
&sender,
client_id,
42,
b"blocked-forward-bytes".to_vec(),
)
.await
.unwrap();
{
let locked = state.lock().unwrap();
let client = locked.sessions["test"].clients.get(&client_id).unwrap();
assert_eq!(client.stream_pending_data[&42].len(), 1);
assert_eq!(client.stream_send_credit[&42], 0);
}
broadcast_output(
&state,
&sender,
PtyOutput {
session: "test".to_string(),
bytes: b"terminal-priority".to_vec(),
exited: false,
},
)
.await
.unwrap();
let mut buf = [0u8; 2048];
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
.await
.unwrap()
.unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
assert_eq!(packet.header.kind, PacketKind::Frame);
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
let frame: Frame = protocol::from_body(&plain).unwrap();
assert_eq!(frame.bytes, b"terminal-priority");
}
#[test]
fn remote_non_loopback_bind_requires_explicit_config() {
let config = ServerConfig::default();
+340 -26
View File
@@ -5,7 +5,9 @@ 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;
@@ -339,6 +341,92 @@ pub fn sign_user_auth(
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,
@@ -347,37 +435,127 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> {
anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519",
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}",
auth.public_key_algorithm
);
anyhow::ensure!(
auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes"
);
anyhow::ensure!(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.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 mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key);
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?;
verifying_key
.verify(&transcript, &signature)
.context("verify native user signature")?;
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,
@@ -470,6 +648,35 @@ pub fn load_ed25519_identity_with_passphrase(
.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,
@@ -565,7 +772,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields"
);
let (options, key_type_index) = if fields[0].starts_with("ssh-") {
let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
(AuthorizedKeyOptions::default(), 0)
} else {
(parse_authorized_key_options(&fields[0])?, 1)
@@ -576,15 +783,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields
.get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
anyhow::ensure!(
algorithm == "ssh-ed25519",
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
let decoded = STANDARD
.decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
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 {
@@ -598,6 +810,51 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
})
}
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()
}
@@ -765,6 +1022,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
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");
@@ -772,6 +1041,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
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;
@@ -1084,6 +1359,45 @@ mod tests {
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]);
+59 -21
View File
@@ -1,6 +1,6 @@
use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
parse_ssh_ed25519_public_blob, user_auth_transcript,
is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
};
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
@@ -12,12 +12,16 @@ const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity {
pub key_blob: Vec<u8>,
pub public_key: [u8; 32],
pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub sign_algorithm: String,
pub sign_flags: u32,
pub comment: String,
}
@@ -38,13 +42,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?;
let identities = request_supported_identities(&mut agent)?;
let identity = identities
.first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?;
.ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(),
public_key: identity.public_key.to_vec(),
public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.clone(),
signature: Vec::new(),
requested_forwardings,
};
@@ -54,7 +58,7 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth)
}
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice();
@@ -72,18 +76,49 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) {
identities.push(AgentIdentity {
key_blob,
public_key,
comment,
});
if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(identity);
}
}
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities)
}
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
let algorithm = key_blob_algorithm(&key_blob)?;
if !is_supported_user_key_algorithm(&algorithm) {
return Ok(None);
}
let identity = match algorithm.as_str() {
"ssh-ed25519" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(),
sign_algorithm: "ssh-ed25519".to_string(),
sign_flags: 0,
key_blob,
comment,
},
"ecdsa-sha2-nistp256" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: key_blob.clone(),
sign_algorithm: algorithm,
sign_flags: 0,
key_blob,
comment,
},
"ssh-rsa" => AgentIdentity {
public_key_algorithm: algorithm,
public_key: key_blob.clone(),
sign_algorithm: "rsa-sha2-512".to_string(),
sign_flags: SSH_AGENT_RSA_SHA2_512,
key_blob,
comment,
},
_ => return Ok(None),
};
Ok(Some(identity))
}
fn sign_with_agent(
agent: &mut UnixStream,
identity: &AgentIdentity,
@@ -93,7 +128,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes());
request.extend_from_slice(&identity.sign_flags.to_be_bytes());
write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?;
@@ -114,20 +149,17 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!(
algorithm == b"ssh-ed25519",
"ssh-agent returned unsupported signature algorithm {}",
String::from_utf8_lossy(algorithm)
algorithm == identity.sign_algorithm,
"ssh-agent returned signature algorithm {algorithm}, expected {}",
identity.sign_algorithm
);
let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!(
signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob"
);
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec())
}
@@ -182,6 +214,12 @@ fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(value);
}
fn 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())
}
#[cfg(test)]
mod tests {
use super::*;