use std::path::Path;
use crate::cert_authority::{parse_known_hosts, CertAuthority, KnownHostsFile, RevokedEntry};
use crate::error::AnvilError;
use crate::ssh_config::lexer::wildcard_match;
pub const DEFAULT_GITHUB_HOST: &str = "github.com";
pub const GITHUB_FALLBACK_HOST: &str = "ssh.github.com";
pub const DEFAULT_GITLAB_HOST: &str = "gitlab.com";
pub const GITLAB_FALLBACK_HOST: &str = "altssh.gitlab.com";
pub const DEFAULT_CODEBERG_HOST: &str = "codeberg.org";
pub const DEFAULT_PORT: u16 = 22;
pub const FALLBACK_PORT: u16 = 443;
#[deprecated(since = "0.2.0", note = "use GITHUB_FALLBACK_HOST instead")]
pub const FALLBACK_HOST: &str = GITHUB_FALLBACK_HOST;
pub const GITHUB_FINGERPRINTS: &[&str] = &[
"SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU", "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM", "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s", ];
pub const GITLAB_FINGERPRINTS: &[&str] = &[
"SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8", "SHA256:HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw", "SHA256:ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ", ];
pub const CODEBERG_FINGERPRINTS: &[&str] = &[
"SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g", "SHA256:T9FYDEHELhVkulEKKwge5aVhVTbqCW0MIRwAfpARs/E", "SHA256:6QQmYi4ppFS4/+zSZ5S4IU+4sa6rwvQ4PbhCtPEBekQ", ];
fn fingerprints_from_known_hosts(path: &Path, hostname: &str) -> Result<Vec<String>, AnvilError> {
let content = std::fs::read_to_string(path)?;
let mut fps = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.splitn(2, ' ');
let Some(host_part) = parts.next() else {
continue;
};
let Some(fp_part) = parts.next() else {
continue;
};
if host_part == hostname {
fps.push(fp_part.trim().to_owned());
}
}
Ok(fps)
}
fn default_known_hosts_path() -> Option<std::path::PathBuf> {
dirs::config_dir().map(|d| d.join("gitway").join("known_hosts"))
}
pub fn fingerprints_for_host(
host: &str,
custom_path: &Option<std::path::PathBuf>,
) -> Result<Vec<String>, AnvilError> {
let mut fps: Vec<String> = match host {
"github.com" | "ssh.github.com" => {
GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
}
"gitlab.com" | "altssh.gitlab.com" => {
GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
}
"codeberg.org" => CODEBERG_FINGERPRINTS
.iter()
.map(|&s| s.to_owned())
.collect(),
_ => Vec::new(),
};
let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
if let Some(ref path) = known_hosts_path {
if path.exists() {
let extras = fingerprints_from_known_hosts(path, host)?;
fps.extend(extras);
}
}
if fps.is_empty() {
return Err(
AnvilError::invalid_config(format!("no fingerprints known for host '{host}'"))
.with_hint(format!(
"Gitway refuses to connect to hosts whose SSH fingerprint it can't \
verify (no trust-on-first-use). Either you typed the hostname \
wrong, or this is a self-hosted server and you need to pin its \
fingerprint: fetch it from the provider's docs (GitHub, GitLab, \
Codeberg publish them) and append one line to \
~/.config/gitway/known_hosts:\n\
\n\
{host} SHA256:<base64-fingerprint>\n\
\n\
As a last resort, re-run with --insecure-skip-host-check (not \
recommended — this disables MITM protection)."
)),
);
}
Ok(fps)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostKeyTrust {
pub fingerprints: Vec<String>,
pub cert_authorities: Vec<CertAuthority>,
pub revoked: Vec<RevokedEntry>,
}
pub fn host_key_trust(
host: &str,
custom_path: &Option<std::path::PathBuf>,
) -> Result<HostKeyTrust, AnvilError> {
let mut trust = HostKeyTrust {
fingerprints: embedded_fingerprints(host),
cert_authorities: Vec::new(),
revoked: Vec::new(),
};
let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
let Some(path) = known_hosts_path else {
return Ok(trust);
};
if !path.exists() {
return Ok(trust);
}
let content = std::fs::read_to_string(&path).map_err(|e| {
AnvilError::invalid_config(format!(
"could not read known_hosts {}: {e}",
path.display(),
))
})?;
let parsed: KnownHostsFile = parse_known_hosts(&content)?;
for direct in parsed.direct {
if wildcard_match(&direct.host_pattern, host) {
trust.fingerprints.push(direct.fingerprint);
}
}
for ca in parsed.cert_authorities {
if wildcard_match(&ca.host_pattern, host) {
trust.cert_authorities.push(ca);
}
}
for rev in parsed.revoked {
if wildcard_match(&rev.host_pattern, host) {
trust.revoked.push(rev);
}
}
Ok(trust)
}
fn embedded_fingerprints(host: &str) -> Vec<String> {
match host {
"github.com" | "ssh.github.com" => {
GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
}
"gitlab.com" | "altssh.gitlab.com" => {
GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
}
"codeberg.org" => CODEBERG_FINGERPRINTS
.iter()
.map(|&s| s.to_owned())
.collect(),
_ => Vec::new(),
}
}
pub(crate) fn append_known_host(
path: &Path,
host: &str,
fingerprint: &str,
) -> Result<(), AnvilError> {
use std::io::Write;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
AnvilError::invalid_config(format!(
"could not create known_hosts parent {}: {e}",
parent.display(),
))
})?;
}
}
let line = format!("{host} {fingerprint}\n");
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)
.map_err(|e| {
AnvilError::invalid_config(format!(
"could not open known_hosts {} for append: {e}",
path.display(),
))
})?;
file.write_all(line.as_bytes()).map_err(|e| {
AnvilError::invalid_config(format!(
"could not write to known_hosts {}: {e}",
path.display(),
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_com_returns_three_fingerprints() {
let fps = fingerprints_for_host("github.com", &None).unwrap();
assert_eq!(fps.len(), 3);
}
#[test]
fn ssh_github_com_returns_same_fingerprints() {
let fps = fingerprints_for_host("ssh.github.com", &None).unwrap();
assert_eq!(fps.len(), 3);
}
#[test]
fn gitlab_com_returns_three_fingerprints() {
let fps = fingerprints_for_host("gitlab.com", &None).unwrap();
assert_eq!(fps.len(), 3);
}
#[test]
fn altssh_gitlab_com_returns_same_fingerprints_as_gitlab() {
let primary = fingerprints_for_host("gitlab.com", &None).unwrap();
let fallback = fingerprints_for_host("altssh.gitlab.com", &None).unwrap();
assert_eq!(primary, fallback);
}
#[test]
fn codeberg_org_returns_three_fingerprints() {
let fps = fingerprints_for_host("codeberg.org", &None).unwrap();
assert_eq!(fps.len(), 3);
}
#[test]
fn all_github_fingerprints_start_with_sha256_prefix() {
for fp in GITHUB_FINGERPRINTS {
assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
}
}
#[test]
fn all_gitlab_fingerprints_start_with_sha256_prefix() {
for fp in GITLAB_FINGERPRINTS {
assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
}
}
#[test]
fn all_codeberg_fingerprints_start_with_sha256_prefix() {
for fp in CODEBERG_FINGERPRINTS {
assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
}
}
#[test]
fn unknown_host_without_known_hosts_is_error() {
let result = fingerprints_for_host("git.example.com", &None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("git.example.com"));
}
fn write_known_hosts(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("known_hosts");
std::fs::write(&path, content).expect("write");
(dir, path)
}
#[test]
fn host_key_trust_embeds_well_known_fingerprints() {
let trust = host_key_trust("github.com", &None).expect("trust");
assert_eq!(trust.fingerprints.len(), 3);
assert!(trust.cert_authorities.is_empty());
assert!(trust.revoked.is_empty());
}
#[test]
fn host_key_trust_pattern_matches_cert_authority() {
let (_g, path) = write_known_hosts(
"@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
);
let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
assert_eq!(trust.cert_authorities.len(), 1);
assert_eq!(trust.cert_authorities[0].host_pattern, "*.example.com");
}
#[test]
fn host_key_trust_pattern_excludes_non_match() {
let (_g, path) = write_known_hosts(
"@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
);
let trust = host_key_trust("other.org", &Some(path)).expect("trust");
assert!(trust.cert_authorities.is_empty());
}
#[test]
fn host_key_trust_revoked_pattern_matches() {
let (_g, path) = write_known_hosts(
"@revoked *.example.com SHA256:revokedfp\n\
@revoked unrelated.com SHA256:other\n",
);
let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
assert_eq!(trust.revoked.len(), 1);
assert_eq!(trust.revoked[0].fingerprint, "SHA256:revokedfp");
}
#[test]
fn host_key_trust_combines_direct_and_embedded() {
let (_g, path) = write_known_hosts("github.com SHA256:extra-pin\n");
let trust = host_key_trust("github.com", &Some(path)).expect("trust");
assert_eq!(trust.fingerprints.len(), 4);
assert!(trust.fingerprints.contains(&"SHA256:extra-pin".to_owned()));
}
#[test]
fn host_key_trust_missing_file_returns_embedded_only() {
let trust = host_key_trust(
"github.com",
&Some(std::path::PathBuf::from("/this/path/does/not/exist")),
)
.expect("trust");
assert_eq!(trust.fingerprints.len(), 3);
assert!(trust.cert_authorities.is_empty());
assert!(trust.revoked.is_empty());
}
#[test]
fn host_key_trust_empty_for_unknown_host_no_file() {
let trust = host_key_trust("git.example.com", &None).expect("trust");
assert!(trust.fingerprints.is_empty());
assert!(trust.cert_authorities.is_empty());
assert!(trust.revoked.is_empty());
}
}