Skip to main content

anvil_ssh/
algorithms.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Algorithm-override surface for SSH negotiation (PRD §5.8.6, M17).
4//!
5//! This module exposes the four moving pieces a downstream CLI needs
6//! to honour [`KexAlgorithms`](https://man.openbsd.org/ssh_config#KexAlgorithms),
7//! `Ciphers`, `MACs`, and `HostKeyAlgorithms` from `~/.ssh/config`
8//! (FR-76) plus the matching CLI overrides (`--kex`, `--ciphers`,
9//! `--macs`, `--host-key-algorithms` — FR-77):
10//!
11//! 1. [`apply_overrides`] parses an OpenSSH-format override string —
12//!    `algo,algo` (replace), `+algo` (append), `-algo` (remove),
13//!    `^algo` (front-load) — against a base list and returns the
14//!    resulting algorithm preference.
15//! 2. [`DENYLIST`] + [`apply_denylist`] enforce FR-78's permanent
16//!    block on broken algorithms (DSA, 3DES, Arcfour, SHA-1 HMAC <
17//!    96 bits, SSH-1) regardless of override.
18//! 3. [`anvil_default_kex`] / `anvil_default_ciphers` /
19//!    `anvil_default_macs` / `anvil_default_host_keys` return the
20//!    *curated* default that's used as the base for `+/-/^` overrides.
21//! 4. [`all_supported`] returns the [`Catalogue`] surfaced by
22//!    `gitway list-algorithms` (FR-79) — every name russh accepts,
23//!    tagged with `is_default` and `denylisted` flags.
24//!
25//! ## Trust model
26//!
27//! Russh 0.59 silently drops unknown algorithm names at negotiation
28//! time — there is no error, no log.  This module validates user
29//! input *before* it reaches russh: an unknown algorithm in an
30//! override surfaces an [`AnvilError::invalid_config`] with a
31//! `tips-thinking` hint pointing at `gitway list-algorithms`.
32//!
33//! The denylist is enforced **after** every override transformation
34//! so a user-supplied `+ssh-dss` cannot bypass FR-78 by smuggling a
35//! banned algorithm through an `^` move.
36
37use crate::error::AnvilError;
38
39// ── Permanent denylist (FR-78) ──────────────────────────────────────────────
40
41/// Permanent denylist — algorithms refused regardless of any override.
42///
43/// Per PRD §5.8.6 FR-78, broken algorithms stay broken: an operator
44/// who needs to talk to a legacy peer must use an external tool
45/// (`ssh -W` proxy + `--insecure-skip-host-check`) rather than
46/// re-enabling them inside Gitway.
47///
48/// Names are lowercase ASCII; matching is case-insensitive via
49/// [`is_denylisted`].  Russh 0.59 already excludes most of these by
50/// default — the explicit list here is a defensive belt-and-suspenders
51/// pass at the override boundary.
52pub const DENYLIST: &[&str] = &[
53    // DSA host keys — broken since RFC 8332 deprecated SHA-1 signatures.
54    "ssh-dss",
55    // 3DES — broken cipher, slow, only 112-bit effective security.
56    "3des-cbc",
57    // Arcfour — broken stream cipher (RFC 8758 deprecates).
58    "arcfour",
59    "arcfour128",
60    "arcfour256",
61    // SHA-1 HMAC truncated below 96 bits — collision-vulnerable.
62    "hmac-sha1-96",
63    // SSH protocol v1 — gone everywhere; defensive belt.
64    "ssh-1.0",
65];
66
67/// Returns `true` iff `alg` is on the permanent denylist
68/// ([`DENYLIST`]).  Comparison is case-insensitive ASCII.
69#[must_use]
70pub fn is_denylisted(alg: &str) -> bool {
71    DENYLIST.iter().any(|d| d.eq_ignore_ascii_case(alg))
72}
73
74/// Filters a list of algorithm names through [`is_denylisted`],
75/// preserving the order of the surviving entries.
76#[must_use]
77pub fn apply_denylist(list: Vec<String>) -> Vec<String> {
78    list.into_iter().filter(|a| !is_denylisted(a)).collect()
79}
80
81// ── Categories ─────────────────────────────────────────────────────────────
82
83/// Algorithm category — the four `ssh_config(5)` directive families
84/// Gitway plumbs through to russh.  Matches the four CLI flags
85/// `--kex` / `--ciphers` / `--macs` / `--host-key-algorithms`.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum AlgCategory {
88    /// `KexAlgorithms` directive / `--kex` flag.
89    Kex,
90    /// `Ciphers` directive / `--ciphers` flag.
91    Cipher,
92    /// `MACs` directive / `--macs` flag.
93    Mac,
94    /// `HostKeyAlgorithms` directive / `--host-key-algorithms` flag.
95    HostKey,
96}
97
98impl AlgCategory {
99    /// Human-readable category label for error messages and the
100    /// `gitway list-algorithms` section headers.
101    #[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
112// ── Override parser (FR-77) ────────────────────────────────────────────────
113
114/// Applies an OpenSSH-format `KexAlgorithms`/etc. override string to
115/// `base`, returning the resulting algorithm list.
116///
117/// # Override syntax
118///
119/// | Prefix | Meaning |
120/// |--------|---------|
121/// | (none)        | Replace `base` entirely with the comma-separated list. |
122/// | `+algo,algo`  | Append the listed algorithms to `base` (deduplicated, denylist-filtered). |
123/// | `-algo,algo`  | Remove the listed algorithms from `base` (no error if absent). |
124/// | `^algo,algo`  | Move the listed algorithms to the front of `base` (preserving their order). |
125/// | (empty)       | No-op — returns `base` unchanged. |
126///
127/// Whitespace around commas is trimmed.  Empty entries (e.g.
128/// `"a,,b"`) are silently dropped.  Comparison is case-insensitive
129/// ASCII.
130///
131/// # FR-78 enforcement
132///
133/// After every transformation, the result is filtered through
134/// [`apply_denylist`].  Additionally, an explicit attempt to
135/// re-enable a denylisted algorithm via `+ssh-dss` (or any prefix)
136/// is surfaced as a hard error with a `tips-thinking` hint — silent
137/// filtering would mask user intent.
138///
139/// # Errors
140///
141/// - The override mentions an algorithm on [`DENYLIST`] (any prefix).
142///
143/// Unknown algorithm names — names not on [`DENYLIST`] but also not
144/// in russh's accepted set — are **not** validated here; that check
145/// belongs to the caller (which has access to [`all_supported`]).
146pub 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    // First-char prefix detection.
157    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    // FR-78: any explicit mention of a denylisted alg is a hard
172    // error, not a silent filter.
173    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            // Drop any front entries that don't appear in the base —
208            // OpenSSH's behaviour (front-loading is reordering, not
209            // adding).
210            front.retain(|t| base.iter().any(|e| e.eq_ignore_ascii_case(t)));
211            // Build the rest = base minus the front entries.
212            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// ── Anvil's curated default lists ──────────────────────────────────────────
232
233/// Returns Anvil's curated default key-exchange algorithm
234/// preference.  Used as the base when an override carries a
235/// `+`/`-`/`^` prefix.
236#[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/// Returns Anvil's curated default cipher preference.
246#[must_use]
247pub fn anvil_default_ciphers() -> Vec<String> {
248    vec!["chacha20-poly1305@openssh.com".to_owned()]
249}
250
251/// Returns Anvil's curated default MAC preference.
252///
253/// AEAD ciphers (chacha20-poly1305, AES-GCM) carry their own
254/// authentication tag, so the explicit MAC list is only consulted
255/// when the negotiated cipher is non-AEAD — a code path Anvil's
256/// default kex/cipher pair never reaches.  Provided for completeness
257/// so an operator overriding the cipher set still gets a sensible
258/// MAC default.
259#[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/// Returns Anvil's curated default host-key algorithm preference.
268#[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// ── Catalogue (FR-79) ──────────────────────────────────────────────────────
281
282/// One entry in the [`Catalogue`] returned by [`all_supported`].
283///
284/// Used to render `gitway list-algorithms` output: a third column on
285/// the human form showing `default` / `available` / `denylisted`,
286/// plus the corresponding flags on the JSON envelope.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct AlgEntry {
289    /// Algorithm name as it appears in the SSH wire protocol.
290    pub name: String,
291    /// `true` if this entry is part of Anvil's curated default for
292    /// its category — i.e. the user gets it without any override.
293    pub is_default: bool,
294    /// `true` if this entry is on [`DENYLIST`] (FR-78).  Surfaces in
295    /// `gitway list-algorithms` so an operator can see why an
296    /// override referencing it would be refused.
297    pub denylisted: bool,
298}
299
300/// Full catalogue of every algorithm Gitway can negotiate, grouped
301/// by [`AlgCategory`].  Returned by [`all_supported`] and consumed
302/// by `gitway list-algorithms`.
303#[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/// Returns the full [`Catalogue`] of algorithms russh advertises plus
312/// the flags `gitway list-algorithms` needs to render the operator-
313/// facing view.
314///
315/// The list is sourced from russh's named constants (e.g.
316/// `russh::kex::CURVE25519`, `russh::cipher::CHACHA20_POLY1305`).
317/// New algorithms a future russh release adds will appear here on
318/// the next anvil-ssh bump.
319///
320/// `is_default` is true iff the entry is in the matching
321/// `anvil_default_*()` list; `denylisted` is true iff
322/// [`is_denylisted`] returns true.  An algorithm cannot be both —
323/// the curated defaults are denylist-clean by construction.
324#[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    // ── Denylist ────────────────────────────────────────────────────────────
395
396    #[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    // ── Override parser ─────────────────────────────────────────────────────
437
438    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        // Base entries preserved in order.
476        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        // No new entries — both already present.
484        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        // Re-order: bring `ext-info-c` to the front.
504        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        // Base entries preserved.
508        assert!(out.contains(&"curve25519-sha256".to_owned()));
509    }
510
511    #[test]
512    fn front_prefix_drops_entries_absent_from_base() {
513        // OpenSSH semantics: `^algo` reorders, doesn't add.
514        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        // `"+a,,b"` — middle empty token discarded.
544        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    // ── Catalogue ───────────────────────────────────────────────────────────
568
569    #[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        // 3des-cbc must show up in the cipher catalogue but tagged
582        // denylisted — operator visibility for FR-78.
583        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    // ── Category labels ─────────────────────────────────────────────────────
624
625    #[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}