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(path: &Path, hostname: &str) -> Result<Vec<String>, GitwayError> {
125    let content = std::fs::read_to_string(path)?;
126    let mut fps = Vec::new();
127
128    for line in content.lines() {
129        let line = line.trim();
130        if line.is_empty() || line.starts_with('#') {
131            continue;
132        }
133        let mut parts = line.splitn(2, ' ');
134        let Some(host_part) = parts.next() else {
135            continue;
136        };
137        let Some(fp_part) = parts.next() else {
138            continue;
139        };
140        if host_part == hostname {
141            fps.push(fp_part.trim().to_owned());
142        }
143    }
144
145    Ok(fps)
146}
147
148/// Returns the default known-hosts path: `~/.config/gitway/known_hosts`.
149fn default_known_hosts_path() -> Option<std::path::PathBuf> {
150    dirs::config_dir().map(|d| d.join("gitway").join("known_hosts"))
151}
152
153// ── Public verifier ───────────────────────────────────────────────────────────
154
155/// Collects all expected fingerprints for `host`.
156///
157/// For well-known hosts (GitHub, GitLab, Codeberg and their fallback
158/// hostnames) the embedded fingerprint set is returned.  For any other host
159/// the custom known-hosts file is consulted; if it provides entries those are
160/// used, otherwise the connection is refused with an actionable error.
161///
162/// # Errors
163///
164/// Returns an error if `custom_path` is specified but cannot be read, or if
165/// no fingerprints can be found for the given host.
166pub fn fingerprints_for_host(
167    host: &str,
168    custom_path: &Option<std::path::PathBuf>,
169) -> Result<Vec<String>, GitwayError> {
170    // Start with the embedded set for the well-known hosted services.
171    let mut fps: Vec<String> = match host {
172        "github.com" | "ssh.github.com" => {
173            GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
174        }
175        "gitlab.com" | "altssh.gitlab.com" => {
176            GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
177        }
178        "codeberg.org" => CODEBERG_FINGERPRINTS
179            .iter()
180            .map(|&s| s.to_owned())
181            .collect(),
182        _ => Vec::new(),
183    };
184
185    // Consult the known-hosts file (user-supplied path or the default location)
186    // to allow custom / self-hosted instances and to let users extend or
187    // override the embedded sets.
188    let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
189
190    if let Some(ref path) = known_hosts_path {
191        if path.exists() {
192            let extras = fingerprints_from_known_hosts(path, host)?;
193            fps.extend(extras);
194        }
195    }
196
197    // No fingerprints at all → refuse the connection with a clear message.
198    if fps.is_empty() {
199        return Err(GitwayError::new(GitwayErrorKind::InvalidConfig {
200            message: format!(
201                "no fingerprints found for host '{host}'; \
202                 add an entry to ~/.config/gitway/known_hosts"
203            ),
204        }));
205    }
206
207    Ok(fps)
208}
209
210// ── Tests ─────────────────────────────────────────────────────────────────────
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn github_com_returns_three_fingerprints() {
218        let fps = fingerprints_for_host("github.com", &None).unwrap();
219        assert_eq!(fps.len(), 3);
220    }
221
222    #[test]
223    fn ssh_github_com_returns_same_fingerprints() {
224        let fps = fingerprints_for_host("ssh.github.com", &None).unwrap();
225        assert_eq!(fps.len(), 3);
226    }
227
228    #[test]
229    fn gitlab_com_returns_three_fingerprints() {
230        let fps = fingerprints_for_host("gitlab.com", &None).unwrap();
231        assert_eq!(fps.len(), 3);
232    }
233
234    #[test]
235    fn altssh_gitlab_com_returns_same_fingerprints_as_gitlab() {
236        let primary = fingerprints_for_host("gitlab.com", &None).unwrap();
237        let fallback = fingerprints_for_host("altssh.gitlab.com", &None).unwrap();
238        assert_eq!(primary, fallback);
239    }
240
241    #[test]
242    fn codeberg_org_returns_three_fingerprints() {
243        let fps = fingerprints_for_host("codeberg.org", &None).unwrap();
244        assert_eq!(fps.len(), 3);
245    }
246
247    #[test]
248    fn all_github_fingerprints_start_with_sha256_prefix() {
249        for fp in GITHUB_FINGERPRINTS {
250            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
251        }
252    }
253
254    #[test]
255    fn all_gitlab_fingerprints_start_with_sha256_prefix() {
256        for fp in GITLAB_FINGERPRINTS {
257            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
258        }
259    }
260
261    #[test]
262    fn all_codeberg_fingerprints_start_with_sha256_prefix() {
263        for fp in CODEBERG_FINGERPRINTS {
264            assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
265        }
266    }
267
268    #[test]
269    fn unknown_host_without_known_hosts_is_error() {
270        let result = fingerprints_for_host("git.example.com", &None);
271        assert!(result.is_err());
272        let err = result.unwrap_err();
273        assert!(err.to_string().contains("git.example.com"));
274    }
275}