use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use super::ConfigError;
use super::glob::glob_match;
use crate::transport::kex::{defaults, is_strict_kex_marker};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlgoCategory {
Cipher,
Mac,
Kex,
HostKey,
PubkeyAccepted,
CaSignature,
}
pub const CA_SIGNATURE_DEFAULTS: &[&str] = &[
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"rsa-sha2-512",
"rsa-sha2-256",
];
pub fn kex_names() -> Vec<&'static str> {
defaults::KEX
.iter()
.copied()
.filter(|n| !is_strict_kex_marker(n))
.collect()
}
pub fn known_names(cat: AlgoCategory) -> Vec<&'static str> {
match cat {
AlgoCategory::Cipher => crate::cipher::ALL.iter().map(|c| c.name).collect(),
AlgoCategory::Mac => crate::mac::ALL.iter().map(|m| m.name).collect(),
AlgoCategory::Kex => kex_names(),
AlgoCategory::HostKey => {
let mut names = crate::hostkey::HOST_KEY_VERIFY_NAMES.to_vec();
names.push("ssh-rsa");
names
}
AlgoCategory::PubkeyAccepted => crate::hostkey::HOST_KEY_VERIFY_NAMES.to_vec(),
AlgoCategory::CaSignature => CA_SIGNATURE_DEFAULTS.to_vec(),
}
}
pub fn default_list(cat: AlgoCategory) -> Vec<&'static str> {
match cat {
AlgoCategory::Cipher => defaults::CIPHERS.to_vec(),
AlgoCategory::Mac => defaults::MACS.to_vec(),
AlgoCategory::Kex => kex_names(),
AlgoCategory::HostKey | AlgoCategory::PubkeyAccepted => {
crate::hostkey::HOST_KEY_VERIFY_NAMES.to_vec()
}
AlgoCategory::CaSignature => CA_SIGNATURE_DEFAULTS.to_vec(),
}
}
fn tokenize(args: &[String]) -> (Option<char>, Vec<String>) {
let joined = args.join(" ");
let modifier = match joined.chars().find(|c| !c.is_whitespace()) {
Some(c @ ('+' | '-' | '^')) => Some(c),
_ => None,
};
let body = if let Some(m) = modifier {
let trimmed = joined.trim_start();
trimmed
.strip_prefix(m)
.map(|s| s.to_string())
.unwrap_or_else(|| trimmed.to_string())
} else {
joined
};
let tokens = body
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
(modifier, tokens)
}
fn bad_value(line_no: usize, keyword: &str, msg: String) -> ConfigError {
ConfigError::BadValue {
line: line_no,
keyword: keyword.to_string(),
msg,
}
}
pub fn resolve_algo_list(
cat: AlgoCategory,
args: &[String],
line_no: usize,
keyword: &str,
) -> Result<Vec<String>, ConfigError> {
let (modifier, tokens) = tokenize(args);
if tokens.is_empty() {
return Err(bad_value(
line_no,
keyword,
"expected at least one algorithm name".to_string(),
));
}
let known = known_names(cat);
let is_known = |name: &str| known.contains(&name);
let result: Vec<String> = match modifier {
Some('-') => default_list(cat)
.into_iter()
.filter(|name| !tokens.iter().any(|pat| glob_match(pat, name)))
.map(|s| s.to_string())
.collect(),
Some('+') => {
for t in &tokens {
if !is_known(t) {
return Err(bad_value(
line_no,
keyword,
format!("unknown algorithm {t:?}"),
));
}
}
let mut out: Vec<String> = default_list(cat)
.into_iter()
.map(|s| s.to_string())
.collect();
for t in tokens {
if !out.contains(&t) {
out.push(t);
}
}
out
}
Some('^') => {
for t in &tokens {
if !is_known(t) {
return Err(bad_value(
line_no,
keyword,
format!("unknown algorithm {t:?}"),
));
}
}
let mut out: Vec<String> = tokens;
for name in default_list(cat) {
if !out.iter().any(|e| e == name) {
out.push(name.to_string());
}
}
out
}
None => {
let mut out: Vec<String> = Vec::with_capacity(tokens.len());
for t in tokens {
if !is_known(&t) {
return Err(bad_value(
line_no,
keyword,
format!("unknown algorithm {t:?}"),
));
}
if !out.contains(&t) {
out.push(t);
}
}
out
}
Some(_) => unreachable!("tokenize only yields +, -, ^"),
};
if result.is_empty() {
return Err(bad_value(
line_no,
keyword,
"directive resolves to an empty algorithm set".to_string(),
));
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(|t| t.to_string()).collect()
}
#[test]
fn bare_list_replaces() {
let got = resolve_algo_list(
AlgoCategory::Cipher,
&args("aes128-ctr,aes256-ctr"),
1,
"Ciphers",
)
.unwrap();
assert_eq!(got, vec!["aes128-ctr", "aes256-ctr"]);
}
#[test]
fn append_modifier() {
let defaults = default_list(AlgoCategory::Mac);
let got = resolve_algo_list(AlgoCategory::Mac, &args("+hmac-sha2-256"), 1, "MACs").unwrap();
assert_eq!(got.len(), defaults.len());
assert!(got.iter().any(|m| m == "hmac-sha2-256"));
}
#[test]
fn append_modifier_adds_new() {
let got =
resolve_algo_list(AlgoCategory::Cipher, &args("+aes128-ctr"), 1, "Ciphers").unwrap();
let count = got.iter().filter(|c| *c == "aes128-ctr").count();
assert_eq!(count, 1, "append must not duplicate an existing entry");
}
#[test]
fn remove_glob() {
let got = resolve_algo_list(AlgoCategory::Cipher, &args("-aes*"), 1, "Ciphers").unwrap();
assert!(got.iter().all(|c| !c.starts_with("aes")));
assert!(got.iter().any(|c| c == "chacha20-poly1305@openssh.com"));
}
#[test]
fn remove_glob_unmatched_is_ok() {
let got = resolve_algo_list(
AlgoCategory::Cipher,
&args("-nonexistent-cipher"),
1,
"Ciphers",
)
.unwrap();
assert_eq!(got.len(), default_list(AlgoCategory::Cipher).len());
}
#[test]
fn prepend_modifier() {
let got =
resolve_algo_list(AlgoCategory::Cipher, &args("^aes128-ctr"), 1, "Ciphers").unwrap();
assert_eq!(got[0], "aes128-ctr");
assert_eq!(got.len(), default_list(AlgoCategory::Cipher).len());
}
#[test]
fn unknown_name_rejected_with_line() {
let err =
resolve_algo_list(AlgoCategory::Cipher, &args("aes999-ctr"), 7, "Ciphers").unwrap_err();
match err {
ConfigError::BadValue { line, keyword, msg } => {
assert_eq!(line, 7);
assert_eq!(keyword, "Ciphers");
assert!(
msg.contains("aes999-ctr"),
"msg should name the token: {msg}"
);
}
other => panic!("expected BadValue, got {other:?}"),
}
}
#[test]
fn append_unknown_rejected() {
let err =
resolve_algo_list(AlgoCategory::Mac, &args("+hmac-bogus"), 3, "MACs").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { line: 3, .. }));
}
#[test]
fn empty_directive_rejected() {
let err = resolve_algo_list(AlgoCategory::Cipher, &[], 2, "Ciphers").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { line: 2, .. }));
}
#[test]
fn remove_everything_is_empty_error() {
let err = resolve_algo_list(AlgoCategory::Cipher, &args("-*"), 4, "Ciphers").unwrap_err();
match err {
ConfigError::BadValue { line, msg, .. } => {
assert_eq!(line, 4);
assert!(msg.contains("empty"), "msg: {msg}");
}
other => panic!("expected BadValue, got {other:?}"),
}
}
#[test]
fn comma_and_whitespace_tolerated() {
let got = resolve_algo_list(
AlgoCategory::Cipher,
&args("aes128-ctr, aes256-ctr , chacha20-poly1305@openssh.com"),
1,
"Ciphers",
)
.unwrap();
assert_eq!(
got,
vec!["aes128-ctr", "aes256-ctr", "chacha20-poly1305@openssh.com"]
);
}
#[test]
fn kex_markers_never_user_visible() {
assert!(
!known_names(AlgoCategory::Kex)
.iter()
.any(|n| is_strict_kex_marker(n))
);
assert!(
!default_list(AlgoCategory::Kex)
.iter()
.any(|n| is_strict_kex_marker(n))
);
let err = resolve_algo_list(
AlgoCategory::Kex,
&args("kex-strict-c-v00@openssh.com"),
1,
"KexAlgorithms",
)
.unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn hostkey_known_names_include_optin_ssh_rsa() {
assert!(known_names(AlgoCategory::HostKey).contains(&"ssh-rsa"));
assert!(known_names(AlgoCategory::HostKey).contains(&"ssh-ed25519"));
assert!(!default_list(AlgoCategory::HostKey).contains(&"ssh-rsa"));
assert!(!known_names(AlgoCategory::PubkeyAccepted).contains(&"ssh-rsa"));
}
#[test]
fn hostkey_plus_ssh_rsa_accepted() {
let got = resolve_algo_list(
AlgoCategory::HostKey,
&args("+ssh-rsa"),
1,
"HostKeyAlgorithms",
)
.unwrap();
assert!(got.iter().any(|n| n == "ssh-rsa"));
assert!(got.iter().any(|n| n == "ssh-ed25519"));
assert!(
got.iter().position(|n| n == "ssh-ed25519") < got.iter().position(|n| n == "ssh-rsa")
);
}
#[test]
fn pubkey_accepted_rejects_ssh_rsa() {
let err = resolve_algo_list(
AlgoCategory::PubkeyAccepted,
&args("+ssh-rsa"),
1,
"PubkeyAcceptedAlgorithms",
)
.unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn pubkey_accepted_uses_hostkey_catalogue() {
let got = resolve_algo_list(
AlgoCategory::PubkeyAccepted,
&args("ssh-ed25519,rsa-sha2-512"),
1,
"PubkeyAcceptedAlgorithms",
)
.unwrap();
assert_eq!(got, vec!["ssh-ed25519", "rsa-sha2-512"]);
}
}