use crate::error::AnvilError;
pub const DENYLIST: &[&str] = &[
"ssh-dss",
"3des-cbc",
"arcfour",
"arcfour128",
"arcfour256",
"hmac-sha1-96",
"ssh-1.0",
];
#[must_use]
pub fn is_denylisted(alg: &str) -> bool {
DENYLIST.iter().any(|d| d.eq_ignore_ascii_case(alg))
}
#[must_use]
pub fn apply_denylist(list: Vec<String>) -> Vec<String> {
list.into_iter().filter(|a| !is_denylisted(a)).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlgCategory {
Kex,
Cipher,
Mac,
HostKey,
}
impl AlgCategory {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Kex => "kex",
Self::Cipher => "cipher",
Self::Mac => "mac",
Self::HostKey => "host-key",
}
}
}
pub fn apply_overrides(
category: AlgCategory,
base: Vec<String>,
override_str: &str,
) -> Result<Vec<String>, AnvilError> {
let trimmed = override_str.trim();
if trimmed.is_empty() {
return Ok(apply_denylist(base));
}
let (prefix, rest) = match trimmed.as_bytes().first().copied() {
Some(b'+') => (Prefix::Append, &trimmed[1..]),
Some(b'-') => (Prefix::Remove, &trimmed[1..]),
Some(b'^') => (Prefix::Front, &trimmed[1..]),
_ => (Prefix::Replace, trimmed),
};
let tokens: Vec<String> = rest
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.collect();
let category_label = category.label();
for tok in &tokens {
if is_denylisted(tok) {
return Err(AnvilError::invalid_config(format!(
"{category_label} override refers to denylisted algorithm '{tok}' (FR-78)",
))
.with_hint(format!(
"Algorithm '{tok}' is permanently disabled in Gitway (PRD §5.8.6 \
FR-78) — it has known cryptographic weaknesses. Run `gitway \
list-algorithms` to see the supported set, or remove the entry \
from your override. If you absolutely need to talk to a peer \
that only speaks '{tok}', use external `ssh -W` as a \
ProxyCommand and accept the security loss explicitly.",
)));
}
}
let result = match prefix {
Prefix::Replace => tokens,
Prefix::Append => {
let mut out = base;
for tok in tokens {
if !out.iter().any(|e| e.eq_ignore_ascii_case(&tok)) {
out.push(tok);
}
}
out
}
Prefix::Remove => base
.into_iter()
.filter(|e| !tokens.iter().any(|t| t.eq_ignore_ascii_case(e)))
.collect(),
Prefix::Front => {
let mut front = tokens.clone();
front.retain(|t| base.iter().any(|e| e.eq_ignore_ascii_case(t)));
let rest: Vec<String> = base
.into_iter()
.filter(|e| !front.iter().any(|f| f.eq_ignore_ascii_case(e)))
.collect();
front.into_iter().chain(rest).collect()
}
};
Ok(apply_denylist(result))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Prefix {
Replace,
Append,
Remove,
Front,
}
#[must_use]
pub fn anvil_default_kex() -> Vec<String> {
vec![
"curve25519-sha256".to_owned(),
"curve25519-sha256@libssh.org".to_owned(),
"ext-info-c".to_owned(),
]
}
#[must_use]
pub fn anvil_default_ciphers() -> Vec<String> {
vec!["chacha20-poly1305@openssh.com".to_owned()]
}
#[must_use]
pub fn anvil_default_macs() -> Vec<String> {
vec![
"hmac-sha2-256-etm@openssh.com".to_owned(),
"hmac-sha2-512-etm@openssh.com".to_owned(),
]
}
#[must_use]
pub fn anvil_default_host_keys() -> Vec<String> {
vec![
"ssh-ed25519".to_owned(),
"ecdsa-sha2-nistp256".to_owned(),
"ecdsa-sha2-nistp384".to_owned(),
"ecdsa-sha2-nistp521".to_owned(),
"rsa-sha2-512".to_owned(),
"rsa-sha2-256".to_owned(),
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlgEntry {
pub name: String,
pub is_default: bool,
pub denylisted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Catalogue {
pub kex: Vec<AlgEntry>,
pub cipher: Vec<AlgEntry>,
pub mac: Vec<AlgEntry>,
pub host_key: Vec<AlgEntry>,
}
#[must_use]
pub fn all_supported() -> Catalogue {
let kex_names = &[
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ext-info-c",
];
let cipher_names = &[
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
];
let mac_names = &[
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha1-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
];
let host_key_names = &[
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
];
Catalogue {
kex: build_entries(kex_names, &anvil_default_kex()),
cipher: build_entries(cipher_names, &anvil_default_ciphers()),
mac: build_entries(mac_names, &anvil_default_macs()),
host_key: build_entries(host_key_names, &anvil_default_host_keys()),
}
}
fn build_entries(names: &[&str], defaults: &[String]) -> Vec<AlgEntry> {
names
.iter()
.map(|n| AlgEntry {
name: (*n).to_owned(),
is_default: defaults.iter().any(|d| d.eq_ignore_ascii_case(n)),
denylisted: is_denylisted(n),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn denylist_is_case_insensitive() {
assert!(is_denylisted("ssh-dss"));
assert!(is_denylisted("SSH-DSS"));
assert!(is_denylisted("Ssh-Dss"));
}
#[test]
fn denylist_rejects_arcfour_variants() {
assert!(is_denylisted("arcfour"));
assert!(is_denylisted("arcfour128"));
assert!(is_denylisted("arcfour256"));
}
#[test]
fn denylist_does_not_block_safe_algorithms() {
assert!(!is_denylisted("curve25519-sha256"));
assert!(!is_denylisted("chacha20-poly1305@openssh.com"));
assert!(!is_denylisted("hmac-sha2-256"));
assert!(!is_denylisted("ssh-ed25519"));
}
#[test]
fn apply_denylist_filters_in_place_preserving_order() {
let input = vec![
"curve25519-sha256".to_owned(),
"ssh-dss".to_owned(),
"chacha20-poly1305@openssh.com".to_owned(),
"3des-cbc".to_owned(),
];
let out = apply_denylist(input);
assert_eq!(
out,
vec![
"curve25519-sha256".to_owned(),
"chacha20-poly1305@openssh.com".to_owned(),
],
);
}
fn base() -> Vec<String> {
vec![
"curve25519-sha256".to_owned(),
"curve25519-sha256@libssh.org".to_owned(),
"ext-info-c".to_owned(),
]
}
#[test]
fn empty_override_returns_base_unchanged() {
assert_eq!(
apply_overrides(AlgCategory::Kex, base(), "").unwrap(),
base()
);
}
#[test]
fn whitespace_only_override_returns_base_unchanged() {
assert_eq!(
apply_overrides(AlgCategory::Kex, base(), " \t ").unwrap(),
base(),
);
}
#[test]
fn no_prefix_replaces_entirely() {
let out =
apply_overrides(AlgCategory::Kex, base(), "diffie-hellman-group14-sha256").unwrap();
assert_eq!(out, vec!["diffie-hellman-group14-sha256".to_owned()]);
}
#[test]
fn append_prefix_adds_to_base() {
let out =
apply_overrides(AlgCategory::Kex, base(), "+diffie-hellman-group14-sha256").unwrap();
assert_eq!(out.len(), 4);
assert_eq!(out.last().unwrap(), "diffie-hellman-group14-sha256");
assert_eq!(out[0], "curve25519-sha256");
}
#[test]
fn append_prefix_skips_duplicates_case_insensitively() {
let out =
apply_overrides(AlgCategory::Kex, base(), "+CURVE25519-SHA256,ext-info-c").unwrap();
assert_eq!(out, base());
}
#[test]
fn remove_prefix_drops_listed_entries() {
let out = apply_overrides(AlgCategory::Kex, base(), "-ext-info-c").unwrap();
assert_eq!(out.len(), 2);
assert!(!out.iter().any(|e| e == "ext-info-c"));
}
#[test]
fn remove_prefix_silently_ignores_absent_entries() {
let out =
apply_overrides(AlgCategory::Kex, base(), "-diffie-hellman-group14-sha256").unwrap();
assert_eq!(out, base());
}
#[test]
fn front_prefix_moves_listed_entries_to_front_preserving_order() {
let out = apply_overrides(AlgCategory::Kex, base(), "^ext-info-c").unwrap();
assert_eq!(out[0], "ext-info-c");
assert_eq!(out.len(), base().len());
assert!(out.contains(&"curve25519-sha256".to_owned()));
}
#[test]
fn front_prefix_drops_entries_absent_from_base() {
let out =
apply_overrides(AlgCategory::Kex, base(), "^diffie-hellman-group14-sha256").unwrap();
assert_eq!(out, base());
}
#[test]
fn override_with_denylisted_alg_returns_error() {
let err = apply_overrides(AlgCategory::Kex, base(), "+ssh-dss").expect_err("must error");
let msg = format!("{err}");
assert!(msg.contains("ssh-dss"));
assert!(msg.contains("kex"));
assert!(msg.contains("FR-78"));
let hint = err.hint();
assert!(
hint.contains("gitway list-algorithms"),
"hint missing tip; got: {hint}"
);
}
#[test]
fn override_with_denylisted_alg_in_replace_form_also_errors() {
let err = apply_overrides(AlgCategory::Cipher, vec![], "3des-cbc").expect_err("must error");
let msg = format!("{err}");
assert!(msg.contains("3des-cbc"));
assert!(msg.contains("cipher"));
}
#[test]
fn override_drops_empty_tokens() {
let out = apply_overrides(
AlgCategory::Kex,
vec![],
"diffie-hellman-group14-sha256,,ext-info-c",
)
.unwrap();
assert_eq!(out.len(), 2);
}
#[test]
fn override_trims_whitespace_around_commas() {
let out = apply_overrides(
AlgCategory::Kex,
vec![],
" curve25519-sha256 , ext-info-c ",
)
.unwrap();
assert_eq!(
out,
vec!["curve25519-sha256".to_owned(), "ext-info-c".to_owned()],
);
}
#[test]
fn catalogue_has_at_least_one_default_per_category() {
let cat = all_supported();
assert!(cat.kex.iter().any(|e| e.is_default));
assert!(cat.cipher.iter().any(|e| e.is_default));
assert!(cat.mac.iter().any(|e| e.is_default));
assert!(cat.host_key.iter().any(|e| e.is_default));
}
#[test]
fn catalogue_marks_denylisted_entries() {
let cat = all_supported();
let three_des = cat
.cipher
.iter()
.find(|e| e.name == "3des-cbc")
.expect("3des-cbc must appear in the cipher catalogue");
assert!(three_des.denylisted);
assert!(!three_des.is_default);
}
#[test]
fn catalogue_default_and_denylist_are_disjoint() {
let cat = all_supported();
for category in [&cat.kex, &cat.cipher, &cat.mac, &cat.host_key] {
for entry in category {
assert!(
!(entry.is_default && entry.denylisted),
"entry {} is both default AND denylisted",
entry.name,
);
}
}
}
#[test]
fn anvil_default_kex_excludes_denylist() {
for alg in anvil_default_kex() {
assert!(
!is_denylisted(&alg),
"anvil default kex includes denylisted {alg}"
);
}
}
#[test]
fn anvil_default_host_keys_excludes_dsa() {
let defaults = anvil_default_host_keys();
assert!(!defaults.iter().any(|a| a == "ssh-dss"));
assert!(defaults.iter().any(|a| a == "ssh-ed25519"));
}
#[test]
fn category_labels_are_stable() {
assert_eq!(AlgCategory::Kex.label(), "kex");
assert_eq!(AlgCategory::Cipher.label(), "cipher");
assert_eq!(AlgCategory::Mac.label(), "mac");
assert_eq!(AlgCategory::HostKey.label(), "host-key");
}
}