1use crate::error::AnvilError;
38
39pub const DENYLIST: &[&str] = &[
53 "ssh-dss",
55 "3des-cbc",
57 "arcfour",
59 "arcfour128",
60 "arcfour256",
61 "hmac-sha1-96",
63 "ssh-1.0",
65];
66
67#[must_use]
70pub fn is_denylisted(alg: &str) -> bool {
71 DENYLIST.iter().any(|d| d.eq_ignore_ascii_case(alg))
72}
73
74#[must_use]
77pub fn apply_denylist(list: Vec<String>) -> Vec<String> {
78 list.into_iter().filter(|a| !is_denylisted(a)).collect()
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum AlgCategory {
88 Kex,
90 Cipher,
92 Mac,
94 HostKey,
96}
97
98impl AlgCategory {
99 #[must_use]
102 pub fn label(self) -> &'static str {
103 match self {
104 Self::Kex => "kex",
105 Self::Cipher => "cipher",
106 Self::Mac => "mac",
107 Self::HostKey => "host-key",
108 }
109 }
110}
111
112pub fn apply_overrides(
147 category: AlgCategory,
148 base: Vec<String>,
149 override_str: &str,
150) -> Result<Vec<String>, AnvilError> {
151 let trimmed = override_str.trim();
152 if trimmed.is_empty() {
153 return Ok(apply_denylist(base));
154 }
155
156 let (prefix, rest) = match trimmed.as_bytes().first().copied() {
158 Some(b'+') => (Prefix::Append, &trimmed[1..]),
159 Some(b'-') => (Prefix::Remove, &trimmed[1..]),
160 Some(b'^') => (Prefix::Front, &trimmed[1..]),
161 _ => (Prefix::Replace, trimmed),
162 };
163
164 let tokens: Vec<String> = rest
165 .split(',')
166 .map(str::trim)
167 .filter(|s| !s.is_empty())
168 .map(ToOwned::to_owned)
169 .collect();
170
171 let category_label = category.label();
174 for tok in &tokens {
175 if is_denylisted(tok) {
176 return Err(AnvilError::invalid_config(format!(
177 "{category_label} override refers to denylisted algorithm '{tok}' (FR-78)",
178 ))
179 .with_hint(format!(
180 "Algorithm '{tok}' is permanently disabled in Gitway (PRD §5.8.6 \
181 FR-78) — it has known cryptographic weaknesses. Run `gitway \
182 list-algorithms` to see the supported set, or remove the entry \
183 from your override. If you absolutely need to talk to a peer \
184 that only speaks '{tok}', use external `ssh -W` as a \
185 ProxyCommand and accept the security loss explicitly.",
186 )));
187 }
188 }
189
190 let result = match prefix {
191 Prefix::Replace => tokens,
192 Prefix::Append => {
193 let mut out = base;
194 for tok in tokens {
195 if !out.iter().any(|e| e.eq_ignore_ascii_case(&tok)) {
196 out.push(tok);
197 }
198 }
199 out
200 }
201 Prefix::Remove => base
202 .into_iter()
203 .filter(|e| !tokens.iter().any(|t| t.eq_ignore_ascii_case(e)))
204 .collect(),
205 Prefix::Front => {
206 let mut front = tokens.clone();
207 front.retain(|t| base.iter().any(|e| e.eq_ignore_ascii_case(t)));
211 let rest: Vec<String> = base
213 .into_iter()
214 .filter(|e| !front.iter().any(|f| f.eq_ignore_ascii_case(e)))
215 .collect();
216 front.into_iter().chain(rest).collect()
217 }
218 };
219
220 Ok(apply_denylist(result))
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224enum Prefix {
225 Replace,
226 Append,
227 Remove,
228 Front,
229}
230
231#[must_use]
237pub fn anvil_default_kex() -> Vec<String> {
238 vec![
239 "curve25519-sha256".to_owned(),
240 "curve25519-sha256@libssh.org".to_owned(),
241 "ext-info-c".to_owned(),
242 ]
243}
244
245#[must_use]
247pub fn anvil_default_ciphers() -> Vec<String> {
248 vec!["chacha20-poly1305@openssh.com".to_owned()]
249}
250
251#[must_use]
260pub fn anvil_default_macs() -> Vec<String> {
261 vec![
262 "hmac-sha2-256-etm@openssh.com".to_owned(),
263 "hmac-sha2-512-etm@openssh.com".to_owned(),
264 ]
265}
266
267#[must_use]
269pub fn anvil_default_host_keys() -> Vec<String> {
270 vec![
271 "ssh-ed25519".to_owned(),
272 "ecdsa-sha2-nistp256".to_owned(),
273 "ecdsa-sha2-nistp384".to_owned(),
274 "ecdsa-sha2-nistp521".to_owned(),
275 "rsa-sha2-512".to_owned(),
276 "rsa-sha2-256".to_owned(),
277 ]
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct AlgEntry {
289 pub name: String,
291 pub is_default: bool,
294 pub denylisted: bool,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct Catalogue {
305 pub kex: Vec<AlgEntry>,
306 pub cipher: Vec<AlgEntry>,
307 pub mac: Vec<AlgEntry>,
308 pub host_key: Vec<AlgEntry>,
309}
310
311#[must_use]
325pub fn all_supported() -> Catalogue {
326 let kex_names = &[
327 "curve25519-sha256",
328 "curve25519-sha256@libssh.org",
329 "diffie-hellman-group18-sha512",
330 "diffie-hellman-group17-sha512",
331 "diffie-hellman-group16-sha512",
332 "diffie-hellman-group15-sha512",
333 "diffie-hellman-group14-sha256",
334 "diffie-hellman-group14-sha1",
335 "diffie-hellman-group1-sha1",
336 "diffie-hellman-group-exchange-sha256",
337 "diffie-hellman-group-exchange-sha1",
338 "ext-info-c",
339 ];
340 let cipher_names = &[
341 "chacha20-poly1305@openssh.com",
342 "aes256-gcm@openssh.com",
343 "aes128-gcm@openssh.com",
344 "aes256-ctr",
345 "aes192-ctr",
346 "aes128-ctr",
347 "aes256-cbc",
348 "aes192-cbc",
349 "aes128-cbc",
350 "3des-cbc",
351 ];
352 let mac_names = &[
353 "hmac-sha2-512-etm@openssh.com",
354 "hmac-sha2-256-etm@openssh.com",
355 "hmac-sha1-etm@openssh.com",
356 "hmac-sha2-512",
357 "hmac-sha2-256",
358 "hmac-sha1",
359 ];
360 let host_key_names = &[
361 "ssh-ed25519",
362 "ecdsa-sha2-nistp256",
363 "ecdsa-sha2-nistp384",
364 "ecdsa-sha2-nistp521",
365 "rsa-sha2-512",
366 "rsa-sha2-256",
367 "ssh-rsa",
368 "ssh-dss",
369 ];
370
371 Catalogue {
372 kex: build_entries(kex_names, &anvil_default_kex()),
373 cipher: build_entries(cipher_names, &anvil_default_ciphers()),
374 mac: build_entries(mac_names, &anvil_default_macs()),
375 host_key: build_entries(host_key_names, &anvil_default_host_keys()),
376 }
377}
378
379fn build_entries(names: &[&str], defaults: &[String]) -> Vec<AlgEntry> {
380 names
381 .iter()
382 .map(|n| AlgEntry {
383 name: (*n).to_owned(),
384 is_default: defaults.iter().any(|d| d.eq_ignore_ascii_case(n)),
385 denylisted: is_denylisted(n),
386 })
387 .collect()
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
397 fn denylist_is_case_insensitive() {
398 assert!(is_denylisted("ssh-dss"));
399 assert!(is_denylisted("SSH-DSS"));
400 assert!(is_denylisted("Ssh-Dss"));
401 }
402
403 #[test]
404 fn denylist_rejects_arcfour_variants() {
405 assert!(is_denylisted("arcfour"));
406 assert!(is_denylisted("arcfour128"));
407 assert!(is_denylisted("arcfour256"));
408 }
409
410 #[test]
411 fn denylist_does_not_block_safe_algorithms() {
412 assert!(!is_denylisted("curve25519-sha256"));
413 assert!(!is_denylisted("chacha20-poly1305@openssh.com"));
414 assert!(!is_denylisted("hmac-sha2-256"));
415 assert!(!is_denylisted("ssh-ed25519"));
416 }
417
418 #[test]
419 fn apply_denylist_filters_in_place_preserving_order() {
420 let input = vec![
421 "curve25519-sha256".to_owned(),
422 "ssh-dss".to_owned(),
423 "chacha20-poly1305@openssh.com".to_owned(),
424 "3des-cbc".to_owned(),
425 ];
426 let out = apply_denylist(input);
427 assert_eq!(
428 out,
429 vec![
430 "curve25519-sha256".to_owned(),
431 "chacha20-poly1305@openssh.com".to_owned(),
432 ],
433 );
434 }
435
436 fn base() -> Vec<String> {
439 vec![
440 "curve25519-sha256".to_owned(),
441 "curve25519-sha256@libssh.org".to_owned(),
442 "ext-info-c".to_owned(),
443 ]
444 }
445
446 #[test]
447 fn empty_override_returns_base_unchanged() {
448 assert_eq!(
449 apply_overrides(AlgCategory::Kex, base(), "").unwrap(),
450 base()
451 );
452 }
453
454 #[test]
455 fn whitespace_only_override_returns_base_unchanged() {
456 assert_eq!(
457 apply_overrides(AlgCategory::Kex, base(), " \t ").unwrap(),
458 base(),
459 );
460 }
461
462 #[test]
463 fn no_prefix_replaces_entirely() {
464 let out =
465 apply_overrides(AlgCategory::Kex, base(), "diffie-hellman-group14-sha256").unwrap();
466 assert_eq!(out, vec!["diffie-hellman-group14-sha256".to_owned()]);
467 }
468
469 #[test]
470 fn append_prefix_adds_to_base() {
471 let out =
472 apply_overrides(AlgCategory::Kex, base(), "+diffie-hellman-group14-sha256").unwrap();
473 assert_eq!(out.len(), 4);
474 assert_eq!(out.last().unwrap(), "diffie-hellman-group14-sha256");
475 assert_eq!(out[0], "curve25519-sha256");
477 }
478
479 #[test]
480 fn append_prefix_skips_duplicates_case_insensitively() {
481 let out =
482 apply_overrides(AlgCategory::Kex, base(), "+CURVE25519-SHA256,ext-info-c").unwrap();
483 assert_eq!(out, base());
485 }
486
487 #[test]
488 fn remove_prefix_drops_listed_entries() {
489 let out = apply_overrides(AlgCategory::Kex, base(), "-ext-info-c").unwrap();
490 assert_eq!(out.len(), 2);
491 assert!(!out.iter().any(|e| e == "ext-info-c"));
492 }
493
494 #[test]
495 fn remove_prefix_silently_ignores_absent_entries() {
496 let out =
497 apply_overrides(AlgCategory::Kex, base(), "-diffie-hellman-group14-sha256").unwrap();
498 assert_eq!(out, base());
499 }
500
501 #[test]
502 fn front_prefix_moves_listed_entries_to_front_preserving_order() {
503 let out = apply_overrides(AlgCategory::Kex, base(), "^ext-info-c").unwrap();
505 assert_eq!(out[0], "ext-info-c");
506 assert_eq!(out.len(), base().len());
507 assert!(out.contains(&"curve25519-sha256".to_owned()));
509 }
510
511 #[test]
512 fn front_prefix_drops_entries_absent_from_base() {
513 let out =
515 apply_overrides(AlgCategory::Kex, base(), "^diffie-hellman-group14-sha256").unwrap();
516 assert_eq!(out, base());
517 }
518
519 #[test]
520 fn override_with_denylisted_alg_returns_error() {
521 let err = apply_overrides(AlgCategory::Kex, base(), "+ssh-dss").expect_err("must error");
522 let msg = format!("{err}");
523 assert!(msg.contains("ssh-dss"));
524 assert!(msg.contains("kex"));
525 assert!(msg.contains("FR-78"));
526 let hint = err.hint();
527 assert!(
528 hint.contains("gitway list-algorithms"),
529 "hint missing tip; got: {hint}"
530 );
531 }
532
533 #[test]
534 fn override_with_denylisted_alg_in_replace_form_also_errors() {
535 let err = apply_overrides(AlgCategory::Cipher, vec![], "3des-cbc").expect_err("must error");
536 let msg = format!("{err}");
537 assert!(msg.contains("3des-cbc"));
538 assert!(msg.contains("cipher"));
539 }
540
541 #[test]
542 fn override_drops_empty_tokens() {
543 let out = apply_overrides(
545 AlgCategory::Kex,
546 vec![],
547 "diffie-hellman-group14-sha256,,ext-info-c",
548 )
549 .unwrap();
550 assert_eq!(out.len(), 2);
551 }
552
553 #[test]
554 fn override_trims_whitespace_around_commas() {
555 let out = apply_overrides(
556 AlgCategory::Kex,
557 vec![],
558 " curve25519-sha256 , ext-info-c ",
559 )
560 .unwrap();
561 assert_eq!(
562 out,
563 vec!["curve25519-sha256".to_owned(), "ext-info-c".to_owned()],
564 );
565 }
566
567 #[test]
570 fn catalogue_has_at_least_one_default_per_category() {
571 let cat = all_supported();
572 assert!(cat.kex.iter().any(|e| e.is_default));
573 assert!(cat.cipher.iter().any(|e| e.is_default));
574 assert!(cat.mac.iter().any(|e| e.is_default));
575 assert!(cat.host_key.iter().any(|e| e.is_default));
576 }
577
578 #[test]
579 fn catalogue_marks_denylisted_entries() {
580 let cat = all_supported();
581 let three_des = cat
584 .cipher
585 .iter()
586 .find(|e| e.name == "3des-cbc")
587 .expect("3des-cbc must appear in the cipher catalogue");
588 assert!(three_des.denylisted);
589 assert!(!three_des.is_default);
590 }
591
592 #[test]
593 fn catalogue_default_and_denylist_are_disjoint() {
594 let cat = all_supported();
595 for category in [&cat.kex, &cat.cipher, &cat.mac, &cat.host_key] {
596 for entry in category {
597 assert!(
598 !(entry.is_default && entry.denylisted),
599 "entry {} is both default AND denylisted",
600 entry.name,
601 );
602 }
603 }
604 }
605
606 #[test]
607 fn anvil_default_kex_excludes_denylist() {
608 for alg in anvil_default_kex() {
609 assert!(
610 !is_denylisted(&alg),
611 "anvil default kex includes denylisted {alg}"
612 );
613 }
614 }
615
616 #[test]
617 fn anvil_default_host_keys_excludes_dsa() {
618 let defaults = anvil_default_host_keys();
619 assert!(!defaults.iter().any(|a| a == "ssh-dss"));
620 assert!(defaults.iter().any(|a| a == "ssh-ed25519"));
621 }
622
623 #[test]
626 fn category_labels_are_stable() {
627 assert_eq!(AlgCategory::Kex.label(), "kex");
628 assert_eq!(AlgCategory::Cipher.label(), "cipher");
629 assert_eq!(AlgCategory::Mac.label(), "mac");
630 assert_eq!(AlgCategory::HostKey.label(), "host-key");
631 }
632}