use crate::probe::handshake::{build_client_hello, parse_server_response, ServerResponse};
use crate::{CipherInventory, CipherSuite};
fn iana_suite_name(id: u16) -> String {
match id {
0x1301 => "TLS_AES_128_GCM_SHA256",
0x1302 => "TLS_AES_256_GCM_SHA384",
0x1303 => "TLS_CHACHA20_POLY1305_SHA256",
0x1304 => "TLS_AES_128_CCM_SHA256",
0x1305 => "TLS_AES_128_CCM_8_SHA256",
0xC02B => "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
0xC02C => "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
0xC02F => "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
0xC030 => "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
0xCCA8 => "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
0xCCA9 => "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
0xC023 => "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
0xC024 => "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384",
0xC027 => "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
0xC028 => "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
0x003C => "TLS_RSA_WITH_AES_128_CBC_SHA256",
0x003D => "TLS_RSA_WITH_AES_256_CBC_SHA256",
0x002F => "TLS_RSA_WITH_AES_128_CBC_SHA",
0x0035 => "TLS_RSA_WITH_AES_256_CBC_SHA",
0x000A => "TLS_RSA_WITH_3DES_EDE_CBC_SHA",
_ => return format!("0x{:04X}", id),
}
.to_string()
}
const TLS13_SUITES: &[u16] = &[
0x1301, 0x1302, 0x1303, 0x1304, 0x1305, ];
const TLS12_SUITES_TO_PROBE: &[u16] = &[
0xC02C, 0xC030, 0xC02B, 0xC02F, 0xCCA8, 0xCCA9, 0xC024, 0xC028, 0xC023, 0xC027, 0x003C, 0x003D, 0x0035, 0x002F, 0x000A,
];
pub fn extract_selected_suite(response: &ServerResponse) -> Option<u16> {
match response {
ServerResponse::ServerHello { selected_suite, .. } => Some(*selected_suite),
_ => None,
}
}
async fn probe_with_suites(
host: &str,
port: u16,
suites: &[u16],
timeout_ms: u64,
max_version: u16,
) -> Option<u16> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let hello = build_client_hello(host, suites, &[0x001D, 0x0017], max_version);
let mut stream = match crate::probe::tcp_connect(host, port, timeout_ms).await {
Ok(s) => s,
Err(_) => return None,
};
if stream.write_all(&hello).await.is_err() {
return None;
}
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
let mut buf = Vec::with_capacity(4096);
loop {
let need = if buf.len() >= 5 {
5 + u16::from_be_bytes([buf[3], buf[4]]) as usize
} else {
5
};
if buf.len() >= need {
break;
}
let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
Some(d) => d,
None => return None,
};
let mut chunk = [0u8; 4096];
match tokio::time::timeout(remaining, stream.read(&mut chunk)).await {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
}
}
parse_server_response(&buf)
.ok()
.and_then(|r| extract_selected_suite(&r))
}
async fn run_tls13_pass(host: &str, port: u16, timeout_ms: u64) -> Vec<CipherSuite> {
let mut remaining: Vec<u16> = TLS13_SUITES.to_vec();
let mut found = Vec::new();
loop {
if remaining.is_empty() {
break;
}
match probe_with_suites(host, port, &remaining, timeout_ms, 0x0304).await {
Some(id) if TLS13_SUITES.contains(&id) => {
found.push(CipherSuite {
id,
name: iana_suite_name(id),
});
remaining.retain(|&s| s != id);
}
_ => break,
}
}
found
}
async fn run_tls12_pass(host: &str, port: u16, timeout_ms: u64) -> Vec<CipherSuite> {
use std::collections::HashSet;
let mut remaining: Vec<u16> = TLS12_SUITES_TO_PROBE.to_vec();
let mut found = Vec::new();
while !remaining.is_empty() {
let batch: Vec<u16> = remaining.iter().copied().take(64).collect();
match probe_with_suites(host, port, &batch, timeout_ms, 0x0303).await {
Some(id) => {
found.push(CipherSuite {
id,
name: iana_suite_name(id),
});
remaining.retain(|&s| s != id);
}
None => {
let batch_set: HashSet<u16> = batch.into_iter().collect();
remaining.retain(|s| !batch_set.contains(s));
}
}
}
found
}
async fn probe_kyber_draft(host: &str, port: u16, timeout_ms: u64) -> bool {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let hello = build_client_hello(host, &[0x1301, 0x1302, 0x1303], &[0x6399], 0x0304);
let mut stream = match crate::probe::tcp_connect(host, port, timeout_ms).await {
Ok(s) => s,
Err(_) => return false,
};
if stream.write_all(&hello).await.is_err() {
return false;
}
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
let mut buf = Vec::with_capacity(4096);
loop {
let need = if buf.len() >= 5 {
5 + u16::from_be_bytes([buf[3], buf[4]]) as usize
} else {
5
};
if buf.len() >= need {
break;
}
let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
Some(d) => d,
None => return false,
};
let mut chunk = [0u8; 4096];
match tokio::time::timeout(remaining, stream.read(&mut chunk)).await {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return false,
Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
}
}
matches!(
parse_server_response(&buf),
Ok(ServerResponse::ServerHello { .. })
)
}
pub async fn enumerate_ciphers(host: &str, port: u16, timeout_ms: u64) -> CipherInventory {
let tls13_suites = run_tls13_pass(host, port, timeout_ms).await;
let tls12_suites = run_tls12_pass(host, port, timeout_ms).await;
let kyber_draft_accepted = probe_kyber_draft(host, port, timeout_ms).await;
CipherInventory {
tls13_suites,
tls12_suites,
kyber_draft_accepted,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_selected_suite_from_server_hello() {
let response = ServerResponse::ServerHello {
selected_suite: 0x1302,
selected_group: None,
tls_version: 0x0303,
};
assert_eq!(extract_selected_suite(&response), Some(0x1302));
}
#[test]
fn extract_selected_suite_from_failure_is_none() {
assert_eq!(
extract_selected_suite(&ServerResponse::HandshakeFailure),
None
);
}
#[test]
fn tls13_suites_list_has_five_entries() {
assert_eq!(TLS13_SUITES.len(), 5);
}
#[test]
fn iana_suite_name_known_id() {
assert_eq!(iana_suite_name(0x1301), "TLS_AES_128_GCM_SHA256");
assert_eq!(iana_suite_name(0x1302), "TLS_AES_256_GCM_SHA384");
assert_eq!(
iana_suite_name(0xC02F),
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
);
}
#[test]
fn iana_suite_name_unknown_falls_back_to_hex() {
assert_eq!(iana_suite_name(0xFFFF), "0xFFFF");
}
}