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}