Skip to main content

anvil_ssh/
hostkey.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! SSH host-key fingerprint pinning for well-known Git hosting services (FR-6, FR-7).
4//!
5//! Gitway embeds the published SHA-256 fingerprints for GitHub, GitLab, and
6//! Codeberg.  On every connection the server's presented key is hashed and the
7//! resulting fingerprint is compared against the embedded list for that host.
8//! Any mismatch aborts the connection immediately.
9//!
10//! # Custom / self-hosted instances
11//!
12//! Fingerprints for any host not listed below can be added via a
13//! `known_hosts`-style file at `~/.config/gitway/known_hosts` (FR-7).
14//! Each non-comment line must follow the format:
15//!
16//! ```text
17//! hostname SHA256:<base64-encoded-fingerprint>
18//! ```
19//!
20//! # Fingerprint sources
21//!
22//! - GitHub:   <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints>
23//! - GitLab:   <https://docs.gitlab.com/ee/user/gitlab_com/index.html#ssh-host-keys-fingerprints>
24//! - Codeberg: <https://docs.codeberg.org/security/ssh-fingerprint/>
25//!
26//! Last verified: 2026-04-11
27
28use std::path::Path;
29
30use crate::cert_authority::{parse_known_hosts, CertAuthority, KnownHostsFile, RevokedEntry};
31use crate::error::AnvilError;
32use crate::ssh_config::lexer::wildcard_match;
33
34// ── Well-known host constants ─────────────────────────────────────────────────
35
36/// Primary GitHub SSH host (FR-1).
37pub const DEFAULT_GITHUB_HOST: &str = "github.com";
38
39/// Fallback GitHub SSH host when port 22 is unavailable (FR-1).
40///
41/// GitHub routes SSH traffic through HTTPS port 443 on this hostname.
42pub const GITHUB_FALLBACK_HOST: &str = "ssh.github.com";
43
44/// Primary GitLab SSH host.
45pub const DEFAULT_GITLAB_HOST: &str = "gitlab.com";
46
47/// Fallback GitLab SSH host when port 22 is unavailable.
48///
49/// GitLab routes SSH traffic through HTTPS port 443 on this hostname.
50pub const GITLAB_FALLBACK_HOST: &str = "altssh.gitlab.com";
51
52/// Primary Codeberg SSH host.
53pub const DEFAULT_CODEBERG_HOST: &str = "codeberg.org";
54
55/// Default SSH port used by all providers.
56///
57/// Changing to a value below 1024 requires elevated privileges on most
58/// POSIX systems; only override this when using a self-hosted instance
59/// with a non-standard port.
60pub const DEFAULT_PORT: u16 = 22;
61
62/// HTTPS-port fallback for providers that support it (GitHub, GitLab).
63pub const FALLBACK_PORT: u16 = 443;
64
65// ── Legacy alias kept for backward compatibility ──────────────────────────────
66
67/// Alias for [`GITHUB_FALLBACK_HOST`]; retained so existing callers that
68/// reference the old name continue to compile.
69#[deprecated(since = "0.2.0", note = "use GITHUB_FALLBACK_HOST instead")]
70pub const FALLBACK_HOST: &str = GITHUB_FALLBACK_HOST;
71
72// ── Embedded fingerprints ─────────────────────────────────────────────────────
73
74/// GitHub's published SSH host-key fingerprints (SHA-256, FR-6).
75///
76/// Contains one entry per key type in `SHA256:<base64>` format:
77/// - Ed25519  (index 0)
78/// - ECDSA    (index 1)
79/// - RSA      (index 2)
80///
81/// **If GitHub rotates its keys, update this constant and cut a patch release.**
82pub const GITHUB_FINGERPRINTS: &[&str] = &[
83    "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU", // Ed25519
84    "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM", // ECDSA-SHA2-nistp256
85    "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s", // RSA
86];
87
88/// GitLab.com's published SSH host-key fingerprints (SHA-256).
89///
90/// Contains one entry per key type in `SHA256:<base64>` format:
91/// - Ed25519  (index 0)
92/// - ECDSA    (index 1)
93/// - RSA      (index 2)
94///
95/// **If GitLab rotates its keys, update this constant and cut a patch release.**
96pub const GITLAB_FINGERPRINTS: &[&str] = &[
97    "SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8", // Ed25519
98    "SHA256:HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw", // ECDSA-SHA2-nistp256
99    "SHA256:ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ", // RSA
100];
101
102/// Codeberg.org's published SSH host-key fingerprints (SHA-256).
103///
104/// Contains one entry per key type in `SHA256:<base64>` format:
105/// - Ed25519  (index 0)
106/// - ECDSA    (index 1)
107/// - RSA      (index 2)
108///
109/// **If Codeberg rotates its keys, update this constant and cut a patch release.**
110pub const CODEBERG_FINGERPRINTS: &[&str] = &[
111    "SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g", // Ed25519
112    "SHA256:T9FYDEHELhVkulEKKwge5aVhVTbqCW0MIRwAfpARs/E", // ECDSA-SHA2-nistp256
113    "SHA256:6QQmYi4ppFS4/+zSZ5S4IU+4sa6rwvQ4PbhCtPEBekQ", // RSA
114];
115
116// ── Known-hosts parser for custom / GHE support ───────────────────────────────
117
118/// Parses a known-hosts file and returns all fingerprints for `hostname`.
119///
120/// Lines starting with `#` and blank lines are ignored. Each valid line has
121/// the form `hostname SHA256:<fp>`.
122///
123/// # Errors
124///
125/// Returns an error if the file cannot be read.
126fn fingerprints_from_known_hosts(path: &Path, hostname: &str) -> Result<Vec<String>, AnvilError> {
127    let content = std::fs::read_to_string(path)?;
128    let mut fps = Vec::new();
129
130    for line in content.lines() {
131        let line = line.trim();
132        if line.is_empty() || line.starts_with('#') {
133            continue;
134        }
135        let mut parts = line.splitn(2, ' ');
136        let Some(host_part) = parts.next() else {
137            continue;
138        };
139        let Some(fp_part) = parts.next() else {
140            continue;
141        };
142        if host_part == hostname {
143            fps.push(fp_part.trim().to_owned());
144        }
145    }
146
147    Ok(fps)
148}
149
150/// Returns the default known-hosts path: `~/.config/gitway/known_hosts`.
151fn default_known_hosts_path() -> Option<std::path::PathBuf> {
152    dirs::config_dir().map(|d| d.join("gitway").join("known_hosts"))
153}
154
155// ── Public verifier ───────────────────────────────────────────────────────────
156
157/// Collects all expected fingerprints for `host`.
158///
159/// For well-known hosts (GitHub, GitLab, Codeberg and their fallback
160/// hostnames) the embedded fingerprint set is returned.  For any other host
161/// the custom known-hosts file is consulted; if it provides entries those are
162/// used, otherwise the connection is refused with an actionable error.
163///
164/// # Errors
165///
166/// Returns an error if `custom_path` is specified but cannot be read, or if
167/// no fingerprints can be found for the given host.
168pub fn fingerprints_for_host(
169    host: &str,
170    custom_path: &Option<std::path::PathBuf>,
171) -> Result<Vec<String>, AnvilError> {
172    // Start with the embedded set for the well-known hosted services.
173    let mut fps: Vec<String> = match host {
174        "github.com" | "ssh.github.com" => {
175            GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
176        }
177        "gitlab.com" | "altssh.gitlab.com" => {
178            GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
179        }
180        "codeberg.org" => CODEBERG_FINGERPRINTS
181            .iter()
182            .map(|&s| s.to_owned())
183            .collect(),
184        _ => Vec::new(),
185    };
186
187    // Consult the known-hosts file (user-supplied path or the default location)
188    // to allow custom / self-hosted instances and to let users extend or
189    // override the embedded sets.
190    let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
191
192    if let Some(ref path) = known_hosts_path {
193        if path.exists() {
194            let extras = fingerprints_from_known_hosts(path, host)?;
195            fps.extend(extras);
196        }
197    }
198
199    // No fingerprints at all → refuse the connection with a clear message.
200    if fps.is_empty() {
201        return Err(
202            AnvilError::invalid_config(format!("no fingerprints known for host '{host}'"))
203                .with_hint(format!(
204                    "Gitway refuses to connect to hosts whose SSH fingerprint it can't \
205             verify (no trust-on-first-use). Either you typed the hostname \
206             wrong, or this is a self-hosted server and you need to pin its \
207             fingerprint: fetch it from the provider's docs (GitHub, GitLab, \
208             Codeberg publish them) and append one line to \
209             ~/.config/gitway/known_hosts:\n\
210             \n\
211                 {host} SHA256:<base64-fingerprint>\n\
212             \n\
213             As a last resort, re-run with --insecure-skip-host-check (not \
214             recommended — this disables MITM protection)."
215                )),
216        );
217    }
218
219    Ok(fps)
220}
221
222// ── M14: combined trust view (FR-60, FR-64) ──────────────────────────────────
223
224/// Combined view of every `known_hosts` entry that bears on the
225/// connection target.
226///
227/// Returned by [`host_key_trust`].  A connection target's effective
228/// trust is the union of:
229///
230/// - `fingerprints` — direct SHA-256 pins (embedded + custom-file).
231///   Identical to what [`fingerprints_for_host`] returns.
232/// - `cert_authorities` — `@cert-authority` entries whose host pattern
233///   matches the target.  Live cert verification (FR-61, FR-62, FR-63)
234///   is deferred until russh exposes the server's certificate; the
235///   field is populated today so `gitway config show --json` and
236///   audit tooling can surface CA identities.
237/// - `revoked` — `@revoked` entries whose host pattern matches.
238///   Enforced first in
239///   [`crate::session::AnvilSession::connect`]'s host-key check: any
240///   presented key whose fingerprint hits one of these is rejected
241///   regardless of `StrictHostKeyChecking` policy.
242#[derive(Debug, Clone, Default, PartialEq, Eq)]
243pub struct HostKeyTrust {
244    pub fingerprints: Vec<String>,
245    pub cert_authorities: Vec<CertAuthority>,
246    pub revoked: Vec<RevokedEntry>,
247}
248
249/// Returns the [`HostKeyTrust`] for `host`, combining the embedded
250/// fingerprint set, any direct pins / `@cert-authority` / `@revoked`
251/// lines from the user-supplied or default `known_hosts` file, and
252/// pattern-matching for the cert-authority + revoked classes.
253///
254/// Unlike [`fingerprints_for_host`], an empty trust set is **not** an
255/// error — the caller decides whether the absence is fatal (the
256/// `StrictHostKeyChecking::AcceptNew` path tolerates an empty set; the
257/// `Yes` path does not).
258///
259/// # Errors
260/// [`AnvilError::invalid_config`] when the known-hosts file exists but
261/// fails to parse (a malformed `@cert-authority` line, for instance).
262/// File-not-found is silently treated as no entries.
263pub fn host_key_trust(
264    host: &str,
265    custom_path: &Option<std::path::PathBuf>,
266) -> Result<HostKeyTrust, AnvilError> {
267    let mut trust = HostKeyTrust {
268        fingerprints: embedded_fingerprints(host),
269        cert_authorities: Vec::new(),
270        revoked: Vec::new(),
271    };
272
273    let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
274    let Some(path) = known_hosts_path else {
275        return Ok(trust);
276    };
277    if !path.exists() {
278        return Ok(trust);
279    }
280
281    let content = std::fs::read_to_string(&path).map_err(|e| {
282        AnvilError::invalid_config(format!(
283            "could not read known_hosts {}: {e}",
284            path.display(),
285        ))
286    })?;
287    let parsed: KnownHostsFile = parse_known_hosts(&content)?;
288
289    for direct in parsed.direct {
290        if wildcard_match(&direct.host_pattern, host) {
291            trust.fingerprints.push(direct.fingerprint);
292        }
293    }
294    for ca in parsed.cert_authorities {
295        if wildcard_match(&ca.host_pattern, host) {
296            trust.cert_authorities.push(ca);
297        }
298    }
299    for rev in parsed.revoked {
300        if wildcard_match(&rev.host_pattern, host) {
301            trust.revoked.push(rev);
302        }
303    }
304
305    Ok(trust)
306}
307
308/// Returns the embedded SHA-256 fingerprints for the listed
309/// well-known hosts.  Internal helper used by both
310/// [`fingerprints_for_host`] and [`host_key_trust`].
311fn embedded_fingerprints(host: &str) -> Vec<String> {
312    match host {
313        "github.com" | "ssh.github.com" => {
314            GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
315        }
316        "gitlab.com" | "altssh.gitlab.com" => {
317            GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
318        }
319        "codeberg.org" => CODEBERG_FINGERPRINTS
320            .iter()
321            .map(|&s| s.to_owned())
322            .collect(),
323        _ => Vec::new(),
324    }
325}
326
327/// Appends `host SHA256:<fingerprint>` as a new line to the `known_hosts`
328/// file at `path`, creating the file (and any missing parent directories)
329/// if needed.
330///
331/// Used by [`crate::ssh_config::StrictHostKeyChecking::AcceptNew`] to
332/// record the fingerprint of an otherwise-unknown host on first
333/// connection.  This is the minimum write surface — file locking and
334/// duplicate-detection are deferred to the post-M12 TOFU UX.
335///
336/// # Errors
337///
338/// Returns an error if the parent directory cannot be created, or if
339/// the file cannot be opened for append, or if the write fails.
340pub(crate) fn append_known_host(
341    path: &Path,
342    host: &str,
343    fingerprint: &str,
344) -> Result<(), AnvilError> {
345    use std::io::Write;
346
347    if let Some(parent) = path.parent() {
348        if !parent.as_os_str().is_empty() {
349            std::fs::create_dir_all(parent).map_err(|e| {
350                AnvilError::invalid_config(format!(
351                    "could not create known_hosts parent {}: {e}",
352                    parent.display(),
353                ))
354            })?;
355        }
356    }
357
358    let line = format!("{host} {fingerprint}\n");
359    let mut file = std::fs::OpenOptions::new()
360        .append(true)
361        .create(true)
362        .open(path)
363        .map_err(|e| {
364            AnvilError::invalid_config(format!(
365                "could not open known_hosts {} for append: {e}",
366                path.display(),
367            ))
368        })?;
369    file.write_all(line.as_bytes()).map_err(|e| {
370        AnvilError::invalid_config(format!(
371            "could not write to known_hosts {}: {e}",
372            path.display(),
373        ))
374    })?;
375
376    Ok(())
377}
378
379// ── Tests ─────────────────────────────────────────────────────────────────────
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn github_com_returns_three_fingerprints() {
387        let fps = fingerprints_for_host("github.com", &None).unwrap();
388        assert_eq!(fps.len(), 3);
389    }
390
391    #[test]
392    fn ssh_github_com_returns_same_fingerprints() {
393        let fps = fingerprints_for_host("ssh.github.com", &None).unwrap();
394        assert_eq!(fps.len(), 3);
395    }
396
397    #[test]
398    fn gitlab_com_returns_three_fingerprints() {
399        let fps = fingerprints_for_host("gitlab.com", &None).unwrap();
400        assert_eq!(fps.len(), 3);
401    }
402
403    #[test]
404    fn altssh_gitlab_com_returns_same_fingerprints_as_gitlab() {
405        let primary = fingerprints_for_host("gitlab.com", &None).unwrap();
406        let fallback = fingerprints_for_host("altssh.gitlab.com", &None).unwrap();
407        assert_eq!(primary, fallback);
408    }
409
410    #[test]
411    fn codeberg_org_returns_three_fingerprints() {
412        let fps = fingerprints_for_host("codeberg.org", &None).unwrap();
413        assert_eq!(fps.len(), 3);
414    }
415
416    #[test]
417    fn all_github_fingerprints_start_with_sha256_prefix() {
418        for fp in GITHUB_FINGERPRINTS {
419            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
420        }
421    }
422
423    #[test]
424    fn all_gitlab_fingerprints_start_with_sha256_prefix() {
425        for fp in GITLAB_FINGERPRINTS {
426            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
427        }
428    }
429
430    #[test]
431    fn all_codeberg_fingerprints_start_with_sha256_prefix() {
432        for fp in CODEBERG_FINGERPRINTS {
433            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
434        }
435    }
436
437    #[test]
438    fn unknown_host_without_known_hosts_is_error() {
439        let result = fingerprints_for_host("git.example.com", &None);
440        assert!(result.is_err());
441        let err = result.unwrap_err();
442        assert!(err.to_string().contains("git.example.com"));
443    }
444
445    // ── M14: host_key_trust ──────────────────────────────────────────────────
446
447    /// Helper: write `content` to a fresh temp file and return its path.
448    fn write_known_hosts(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
449        let dir = tempfile::tempdir().expect("tempdir");
450        let path = dir.path().join("known_hosts");
451        std::fs::write(&path, content).expect("write");
452        (dir, path)
453    }
454
455    #[test]
456    fn host_key_trust_embeds_well_known_fingerprints() {
457        let trust = host_key_trust("github.com", &None).expect("trust");
458        assert_eq!(trust.fingerprints.len(), 3);
459        assert!(trust.cert_authorities.is_empty());
460        assert!(trust.revoked.is_empty());
461    }
462
463    #[test]
464    fn host_key_trust_pattern_matches_cert_authority() {
465        let (_g, path) = write_known_hosts(
466            "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
467        );
468        let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
469        assert_eq!(trust.cert_authorities.len(), 1);
470        assert_eq!(trust.cert_authorities[0].host_pattern, "*.example.com");
471    }
472
473    #[test]
474    fn host_key_trust_pattern_excludes_non_match() {
475        let (_g, path) = write_known_hosts(
476            "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
477        );
478        let trust = host_key_trust("other.org", &Some(path)).expect("trust");
479        assert!(trust.cert_authorities.is_empty());
480    }
481
482    #[test]
483    fn host_key_trust_revoked_pattern_matches() {
484        let (_g, path) = write_known_hosts(
485            "@revoked *.example.com SHA256:revokedfp\n\
486             @revoked unrelated.com SHA256:other\n",
487        );
488        let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
489        assert_eq!(trust.revoked.len(), 1);
490        assert_eq!(trust.revoked[0].fingerprint, "SHA256:revokedfp");
491    }
492
493    #[test]
494    fn host_key_trust_combines_direct_and_embedded() {
495        let (_g, path) = write_known_hosts("github.com SHA256:extra-pin\n");
496        let trust = host_key_trust("github.com", &Some(path)).expect("trust");
497        // Three embedded + one extra direct.
498        assert_eq!(trust.fingerprints.len(), 4);
499        assert!(trust.fingerprints.contains(&"SHA256:extra-pin".to_owned()));
500    }
501
502    #[test]
503    fn host_key_trust_missing_file_returns_embedded_only() {
504        let trust = host_key_trust(
505            "github.com",
506            &Some(std::path::PathBuf::from("/this/path/does/not/exist")),
507        )
508        .expect("trust");
509        assert_eq!(trust.fingerprints.len(), 3);
510        assert!(trust.cert_authorities.is_empty());
511        assert!(trust.revoked.is_empty());
512    }
513
514    #[test]
515    fn host_key_trust_empty_for_unknown_host_no_file() {
516        // Unlike `fingerprints_for_host`, `host_key_trust` does NOT
517        // error on an empty trust set — that is the caller's policy
518        // call.  This is the path the AcceptNew policy relies on.
519        let trust = host_key_trust("git.example.com", &None).expect("trust");
520        assert!(trust.fingerprints.is_empty());
521        assert!(trust.cert_authorities.is_empty());
522        assert!(trust.revoked.is_empty());
523    }
524}