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)]
#[non_exhaustive]
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()
}
const LEGACY_KEX: &[&str] = &[
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
];
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 => {
let mut v = kex_names();
v.extend_from_slice(LEGACY_KEX);
v
}
AlgoCategory::HostKey | AlgoCategory::PubkeyAccepted => {
let mut names = default_list(cat);
names.push("ssh-mldsa44-ed25519-cert-v01@openssh.com");
names.push("ssh-mldsa44-ed25519@openssh.com");
names.push("ssh-rsa-cert-v01@openssh.com");
names.push("ssh-rsa");
names
}
AlgoCategory::CaSignature => {
let mut names = CA_SIGNATURE_DEFAULTS.to_vec();
names.push("ssh-mldsa44-ed25519@openssh.com");
names
}
}
}
fn openssh_only(cat: AlgoCategory) -> &'static [&'static str] {
const CIPHERS: &[&str] = &[
"3des-cbc",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"rijndael-cbc@lysator.liu.se",
"blowfish-cbc",
"cast128-cbc",
"arcfour",
"arcfour128",
"arcfour256",
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
];
const MACS: &[&str] = &[
"hmac-sha1",
"hmac-sha1-96",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-md5",
"hmac-md5-96",
"umac-64@openssh.com",
"umac-128@openssh.com",
"hmac-sha1-etm@openssh.com",
"hmac-sha1-96-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-md5-etm@openssh.com",
"hmac-md5-96-etm@openssh.com",
"umac-64-etm@openssh.com",
"umac-128-etm@openssh.com",
];
const KEX: &[&str] = &[
"diffie-hellman-group1-sha1",
"diffie-hellman-group14-sha1",
"diffie-hellman-group14-sha256",
"diffie-hellman-group16-sha512",
"diffie-hellman-group18-sha512",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group-exchange-sha256",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"sntrup761x25519-sha512",
"sntrup761x25519-sha512@openssh.com",
"mlkem768x25519-sha256",
];
const KEYS: &[&str] = &[
"ssh-ed25519",
"ssh-ed25519-cert-v01@openssh.com",
"sk-ssh-ed25519@openssh.com",
"sk-ssh-ed25519-cert-v01@openssh.com",
"ssh-mldsa44-ed25519@openssh.com",
"ssh-mldsa44-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
"webauthn-sk-ecdsa-sha2-nistp256@openssh.com",
"webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ssh-dss",
"ssh-dss-cert-v01@openssh.com",
"ssh-rsa",
"ssh-rsa-cert-v01@openssh.com",
"rsa-sha2-256",
"rsa-sha2-256-cert-v01@openssh.com",
"rsa-sha2-512",
"rsa-sha2-512-cert-v01@openssh.com",
];
match cat {
AlgoCategory::Cipher => CIPHERS,
AlgoCategory::Mac => MACS,
AlgoCategory::Kex => KEX,
AlgoCategory::HostKey | AlgoCategory::PubkeyAccepted | AlgoCategory::CaSignature => KEYS,
}
}
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 => {
let mut v = crate::cert::CERT_KEY_NAMES.to_vec();
v.extend_from_slice(crate::hostkey::HOST_KEY_VERIFY_NAMES);
v
}
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 named = tokens.len();
let tokens: Vec<String> = if modifier == Some('-') {
tokens
} else {
tokens
.into_iter()
.filter(|t| is_known(t) || !openssh_only(cat).contains(&t.as_str()))
.collect()
};
if named > 0 && tokens.is_empty() && modifier.is_none() {
return Err(bad_value(
line_no,
keyword,
"none of these algorithms is implemented by puressh".to_string(),
));
}
let expand = |t: &str| -> Result<Vec<String>, ConfigError> {
if t.contains(['*', '?']) {
return Ok(known
.iter()
.filter(|n| glob_match(t, n))
.map(|n| n.to_string())
.collect());
}
if !is_known(t) {
return Err(bad_value(
line_no,
keyword,
format!("unknown algorithm {t:?}"),
));
}
Ok(alloc::vec![t.to_string()])
};
let push_unique = |out: &mut Vec<String>, names: Vec<String>| {
for n in names {
if !out.contains(&n) {
out.push(n);
}
}
};
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('+') => {
let mut out: Vec<String> = default_list(cat)
.into_iter()
.map(|s| s.to_string())
.collect();
for t in &tokens {
push_unique(&mut out, expand(t)?);
}
out
}
Some('^') => {
let mut out: Vec<String> = Vec::new();
for t in &tokens {
push_unique(&mut out, expand(t)?);
}
push_unique(
&mut out,
default_list(cat)
.into_iter()
.map(|s| s.to_string())
.collect(),
);
out
}
None => {
let mut out: Vec<String> = Vec::with_capacity(tokens.len());
for t in &tokens {
push_unique(&mut out, expand(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"));
assert!(!default_list(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_plus_ssh_rsa_appends_it() {
let got = resolve_algo_list(
AlgoCategory::PubkeyAccepted,
&args("+ssh-rsa"),
1,
"PubkeyAcceptedAlgorithms",
)
.unwrap();
let mut want = default_list(AlgoCategory::PubkeyAccepted);
want.push("ssh-rsa");
assert_eq!(got, want);
}
#[test]
fn openssh_only_names_are_dropped_unknown_ones_refused() {
let got = resolve_algo_list(
AlgoCategory::Cipher,
&args("+blowfish-cbc,arcfour"),
1,
"Ciphers",
)
.unwrap();
assert_eq!(got, default_list(AlgoCategory::Cipher));
let got = resolve_algo_list(
AlgoCategory::Cipher,
&args("arcfour256,aes256-ctr"),
1,
"Ciphers",
)
.unwrap();
assert_eq!(got, vec!["aes256-ctr"]);
let got = resolve_algo_list(
AlgoCategory::Cipher,
&args("+aes128-cbc,3des-cbc"),
1,
"Ciphers",
)
.unwrap();
assert_eq!(&got[got.len() - 2..], &["aes128-cbc", "3des-cbc"]);
assert!(
resolve_algo_list(AlgoCategory::Cipher, &args("blowfish-cbc"), 1, "Ciphers").is_err()
);
assert!(
resolve_algo_list(AlgoCategory::Cipher, &args("+aes128-cbx"), 1, "Ciphers").is_err()
);
assert!(
resolve_algo_list(
AlgoCategory::Kex,
&args("+diffie-hellman-group1-sha1"),
1,
"KexAlgorithms"
)
.is_ok()
);
}
#[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"]);
}
#[test]
fn patterns_stand_for_the_supported_names_they_match() {
let all = resolve_algo_list(AlgoCategory::PubkeyAccepted, &args("*"), 1, "k").unwrap();
assert_eq!(all, known_names(AlgoCategory::PubkeyAccepted));
let ctr = resolve_algo_list(AlgoCategory::Cipher, &args("aes*-ctr"), 1, "k").unwrap();
assert_eq!(ctr, ["aes256-ctr", "aes192-ctr", "aes128-ctr"]);
let plus = resolve_algo_list(AlgoCategory::Mac, &args("+hmac-md5*"), 1, "k").unwrap();
assert!(plus.iter().any(|m| m == "hmac-md5-96"));
assert!(resolve_algo_list(AlgoCategory::Cipher, &args("nope*"), 1, "k").is_err());
}
}