Skip to main content

gitway_lib/
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::error::{GitwayError, GitwayErrorKind};
31
32// ── Well-known host constants ─────────────────────────────────────────────────
33
34/// Primary GitHub SSH host (FR-1).
35pub const DEFAULT_GITHUB_HOST: &str = "github.com";
36
37/// Fallback GitHub SSH host when port 22 is unavailable (FR-1).
38///
39/// GitHub routes SSH traffic through HTTPS port 443 on this hostname.
40pub const GITHUB_FALLBACK_HOST: &str = "ssh.github.com";
41
42/// Primary GitLab SSH host.
43pub const DEFAULT_GITLAB_HOST: &str = "gitlab.com";
44
45/// Fallback GitLab SSH host when port 22 is unavailable.
46///
47/// GitLab routes SSH traffic through HTTPS port 443 on this hostname.
48pub const GITLAB_FALLBACK_HOST: &str = "altssh.gitlab.com";
49
50/// Primary Codeberg SSH host.
51pub const DEFAULT_CODEBERG_HOST: &str = "codeberg.org";
52
53/// Default SSH port used by all providers.
54///
55/// Changing to a value below 1024 requires elevated privileges on most
56/// POSIX systems; only override this when using a self-hosted instance
57/// with a non-standard port.
58pub const DEFAULT_PORT: u16 = 22;
59
60/// HTTPS-port fallback for providers that support it (GitHub, GitLab).
61pub const FALLBACK_PORT: u16 = 443;
62
63// ── Legacy alias kept for backward compatibility ──────────────────────────────
64
65/// Alias for [`GITHUB_FALLBACK_HOST`]; retained so existing callers that
66/// reference the old name continue to compile.
67#[deprecated(since = "0.2.0", note = "use GITHUB_FALLBACK_HOST instead")]
68pub const FALLBACK_HOST: &str = GITHUB_FALLBACK_HOST;
69
70// ── Embedded fingerprints ─────────────────────────────────────────────────────
71
72/// GitHub's published SSH host-key fingerprints (SHA-256, FR-6).
73///
74/// Contains one entry per key type in `SHA256:<base64>` format:
75/// - Ed25519  (index 0)
76/// - ECDSA    (index 1)
77/// - RSA      (index 2)
78///
79/// **If GitHub rotates its keys, update this constant and cut a patch release.**
80pub const GITHUB_FINGERPRINTS: &[&str] = &[
81    "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU", // Ed25519
82    "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM", // ECDSA-SHA2-nistp256
83    "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s", // RSA
84];
85
86/// GitLab.com's published SSH host-key fingerprints (SHA-256).
87///
88/// Contains one entry per key type in `SHA256:<base64>` format:
89/// - Ed25519  (index 0)
90/// - ECDSA    (index 1)
91/// - RSA      (index 2)
92///
93/// **If GitLab rotates its keys, update this constant and cut a patch release.**
94pub const GITLAB_FINGERPRINTS: &[&str] = &[
95    "SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8", // Ed25519
96    "SHA256:HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw", // ECDSA-SHA2-nistp256
97    "SHA256:ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ",  // RSA
98];
99
100/// Codeberg.org's published SSH host-key fingerprints (SHA-256).
101///
102/// Contains one entry per key type in `SHA256:<base64>` format:
103/// - Ed25519  (index 0)
104/// - ECDSA    (index 1)
105/// - RSA      (index 2)
106///
107/// **If Codeberg rotates its keys, update this constant and cut a patch release.**
108pub const CODEBERG_FINGERPRINTS: &[&str] = &[
109    "SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g", // Ed25519
110    "SHA256:T9FYDEHELhVkulEKKwge5aVhVTbqCW0MIRwAfpARs/E",  // ECDSA-SHA2-nistp256
111    "SHA256:6QQmYi4ppFS4/+zSZ5S4IU+4sa6rwvQ4PbhCtPEBekQ",  // RSA
112];
113
114// ── Known-hosts parser for custom / GHE support ───────────────────────────────
115
116/// Parses a known-hosts file and returns all fingerprints for `hostname`.
117///
118/// Lines starting with `#` and blank lines are ignored. Each valid line has
119/// the form `hostname SHA256:<fp>`.
120///
121/// # Errors
122///
123/// Returns an error if the file cannot be read.
124fn fingerprints_from_known_hosts(
125    path: &Path,
126    hostname: &str,
127) -> Result<Vec<String>, GitwayError> {
128    let content = std::fs::read_to_string(path)?;
129    let mut fps = Vec::new();
130
131    for line in content.lines() {
132        let line = line.trim();
133        if line.is_empty() || line.starts_with('#') {
134            continue;
135        }
136        let mut parts = line.splitn(2, ' ');
137        let Some(host_part) = parts.next() else {
138            continue;
139        };
140        let Some(fp_part) = parts.next() else {
141            continue;
142        };
143        if host_part == hostname {
144            fps.push(fp_part.trim().to_owned());
145        }
146    }
147
148    Ok(fps)
149}
150
151/// Returns the default known-hosts path: `~/.config/gitway/known_hosts`.
152fn default_known_hosts_path() -> Option<std::path::PathBuf> {
153    dirs::config_dir().map(|d| d.join("gitway").join("known_hosts"))
154}
155
156// ── Public verifier ───────────────────────────────────────────────────────────
157
158/// Collects all expected fingerprints for `host`.
159///
160/// For well-known hosts (GitHub, GitLab, Codeberg and their fallback
161/// hostnames) the embedded fingerprint set is returned.  For any other host
162/// the custom known-hosts file is consulted; if it provides entries those are
163/// used, otherwise the connection is refused with an actionable error.
164///
165/// # Errors
166///
167/// Returns an error if `custom_path` is specified but cannot be read, or if
168/// no fingerprints can be found for the given host.
169pub fn fingerprints_for_host(
170    host: &str,
171    custom_path: &Option<std::path::PathBuf>,
172) -> Result<Vec<String>, GitwayError> {
173    // Start with the embedded set for the well-known hosted services.
174    let mut fps: Vec<String> = match host {
175        "github.com" | "ssh.github.com" => {
176            GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
177        }
178        "gitlab.com" | "altssh.gitlab.com" => {
179            GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
180        }
181        "codeberg.org" => {
182            CODEBERG_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
183        }
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(GitwayError::new(GitwayErrorKind::InvalidConfig {
202            message: format!(
203                "no fingerprints found for host '{host}'; \
204                 add an entry to ~/.config/gitway/known_hosts"
205            ),
206        }));
207    }
208
209    Ok(fps)
210}
211
212// ── Tests ─────────────────────────────────────────────────────────────────────
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn github_com_returns_three_fingerprints() {
220        let fps = fingerprints_for_host("github.com", &None).unwrap();
221        assert_eq!(fps.len(), 3);
222    }
223
224    #[test]
225    fn ssh_github_com_returns_same_fingerprints() {
226        let fps = fingerprints_for_host("ssh.github.com", &None).unwrap();
227        assert_eq!(fps.len(), 3);
228    }
229
230    #[test]
231    fn gitlab_com_returns_three_fingerprints() {
232        let fps = fingerprints_for_host("gitlab.com", &None).unwrap();
233        assert_eq!(fps.len(), 3);
234    }
235
236    #[test]
237    fn altssh_gitlab_com_returns_same_fingerprints_as_gitlab() {
238        let primary = fingerprints_for_host("gitlab.com", &None).unwrap();
239        let fallback = fingerprints_for_host("altssh.gitlab.com", &None).unwrap();
240        assert_eq!(primary, fallback);
241    }
242
243    #[test]
244    fn codeberg_org_returns_three_fingerprints() {
245        let fps = fingerprints_for_host("codeberg.org", &None).unwrap();
246        assert_eq!(fps.len(), 3);
247    }
248
249    #[test]
250    fn all_github_fingerprints_start_with_sha256_prefix() {
251        for fp in GITHUB_FINGERPRINTS {
252            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
253        }
254    }
255
256    #[test]
257    fn all_gitlab_fingerprints_start_with_sha256_prefix() {
258        for fp in GITLAB_FINGERPRINTS {
259            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
260        }
261    }
262
263    #[test]
264    fn all_codeberg_fingerprints_start_with_sha256_prefix() {
265        for fp in CODEBERG_FINGERPRINTS {
266            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
267        }
268    }
269
270    #[test]
271    fn unknown_host_without_known_hosts_is_error() {
272        let result = fingerprints_for_host("git.example.com", &None);
273        assert!(result.is_err());
274        let err = result.unwrap_err();
275        assert!(err.to_string().contains("git.example.com"));
276    }
277}