use std::path::Path;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use hmac::{Hmac, Mac};
use rand_core::{OsRng, RngCore};
use sha1::Sha1;
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)
}
#[must_use]
pub 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 fn append_known_host(path: &Path, host: &str, fingerprint: &str) -> Result<(), AnvilError> {
use std::io::Write;
ensure_parent_exists(path)?;
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(())
}
pub fn append_known_host_hashed(
path: &Path,
host: &str,
fingerprint: &str,
) -> Result<(), AnvilError> {
use std::io::Write;
ensure_parent_exists(path)?;
let mut salt = [0u8; 20];
OsRng.fill_bytes(&mut salt);
let mut mac = <Hmac<Sha1>>::new_from_slice(&salt).map_err(|_e| {
AnvilError::invalid_config(
"HMAC-SHA1 init failed unexpectedly; refusing to write hashed entry".to_owned(),
)
})?;
mac.update(host.as_bytes());
let hash = mac.finalize().into_bytes();
let line = format!(
"|1|{}|{} {fingerprint}\n",
BASE64.encode(salt),
BASE64.encode(hash.as_slice()),
);
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(())
}
pub fn prepend_revoked(
path: &Path,
host_pattern: &str,
fingerprint: &str,
) -> Result<(), AnvilError> {
use std::io::Write;
const MAX_FILE_BYTES: u64 = 1024 * 1024;
ensure_parent_exists(path)?;
let existing: Vec<u8> = if path.exists() {
let metadata = std::fs::metadata(path).map_err(|e| {
AnvilError::invalid_config(format!(
"could not stat known_hosts {} for revoke: {e}",
path.display(),
))
})?;
if metadata.len() > MAX_FILE_BYTES {
return Err(AnvilError::invalid_config(format!(
"known_hosts {} is larger than {MAX_FILE_BYTES} bytes; refusing to load \
entire file into memory for revoke. Split the file or pass --known-hosts \
to point at a smaller one.",
path.display(),
)));
}
std::fs::read(path).map_err(|e| {
AnvilError::invalid_config(format!(
"could not read known_hosts {} for revoke: {e}",
path.display(),
))
})?
} else {
Vec::new()
};
let mut suffix_bytes = [0u8; 8];
OsRng.fill_bytes(&mut suffix_bytes);
let suffix = BASE64
.encode(suffix_bytes)
.replace('/', "_")
.replace('+', "-");
let tmp_path = path.with_extension(format!("revoke.{suffix}.tmp"));
let mut tmp = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.map_err(|e| {
AnvilError::invalid_config(format!(
"could not create temp file {} for revoke: {e}",
tmp_path.display(),
))
})?;
let new_line = format!("@revoked {host_pattern} {fingerprint}\n");
tmp.write_all(new_line.as_bytes())
.map_err(|e| AnvilError::invalid_config(format!("could not write revoke header: {e}")))?;
tmp.write_all(&existing).map_err(|e| {
AnvilError::invalid_config(format!("could not copy existing known_hosts contents: {e}"))
})?;
tmp.sync_all().map_err(|e| {
AnvilError::invalid_config(format!("could not fsync temp file before rename: {e}"))
})?;
drop(tmp);
std::fs::rename(&tmp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
AnvilError::invalid_config(format!(
"could not rename {} -> {}: {e}",
tmp_path.display(),
path.display(),
))
})?;
Ok(())
}
#[must_use]
pub fn all_embedded() -> Vec<(String, String, &'static str)> {
const ALGS: [&str; 3] = ["ed25519", "ecdsa", "rsa"];
let mut out = Vec::with_capacity(9);
for (host, fps) in [
("github.com", GITHUB_FINGERPRINTS),
("gitlab.com", GITLAB_FINGERPRINTS),
("codeberg.org", CODEBERG_FINGERPRINTS),
] {
for (idx, fp) in fps.iter().enumerate() {
let alg = ALGS.get(idx).copied().unwrap_or("unknown");
out.push((host.to_owned(), (*fp).to_owned(), alg));
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashMode {
Empty,
Plaintext,
Hashed,
}
pub fn detect_hash_mode(path: &Path) -> Result<HashMode, AnvilError> {
if !path.exists() {
return Ok(HashMode::Empty);
}
let content = std::fs::read_to_string(path).map_err(|e| {
AnvilError::invalid_config(format!(
"could not read known_hosts {} for hash-mode detect: {e}",
path.display(),
))
})?;
let mut saw_plaintext = false;
for raw in content.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('@') {
continue;
}
let host_token = line.split_whitespace().next().unwrap_or("");
if host_token.starts_with("|1|") {
return Ok(HashMode::Hashed);
}
saw_plaintext = true;
}
if saw_plaintext {
Ok(HashMode::Plaintext)
} else {
Ok(HashMode::Empty)
}
}
fn ensure_parent_exists(path: &Path) -> Result<(), AnvilError> {
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(),
))
})?;
}
}
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());
}
}