From 300ccf5f1fdfa0151c555dc3ad7aebaaeede2d90 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Wed, 26 Aug 2026 09:32:21 +0200 Subject: [PATCH] Use repository Git remotes and SSH config --- crates/storage/src/config.rs | 82 +++++++++- crates/storage/src/git.rs | 8 +- crates/storage/src/ssh.rs | 196 ++++++++++++++++++++++-- crates/storage/tests/config_contract.rs | 31 ++++ docs/configuration.md | 6 +- docs/git-synchronization.md | 11 +- docs/ssh-transport-audit.md | 5 +- 7 files changed, 316 insertions(+), 23 deletions(-) diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index e0f3d1d..b5ec17d 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -13,7 +13,11 @@ use std::{ use cap_std::{ambient_authority, fs::Dir}; use cap_tempfile::TempFile; +#[cfg(not(any(target_os = "ios", target_os = "watchos")))] +use gix::bstr::ByteSlice as _; use serde::Deserialize; +#[cfg(not(any(target_os = "ios", target_os = "watchos")))] +use sha2::{Digest as _, Sha256}; use url::Url; use crate::{ @@ -1694,7 +1698,14 @@ fn validate_config( }); } }; - let git_remotes = validate_remotes(raw.git.remotes, Some(base))?; + let configured_git_remotes = raw.git.remotes; + #[cfg(not(any(target_os = "ios", target_os = "watchos")))] + let git_remotes = match repository_git_remotes(&vault)? { + Some(remotes) => remotes, + None => validate_remotes(configured_git_remotes, Some(base))?, + }; + #[cfg(any(target_os = "ios", target_os = "watchos"))] + let git_remotes = validate_remotes(configured_git_remotes, Some(base))?; Ok(Config { source, @@ -1715,6 +1726,75 @@ fn validate_config( }) } +#[cfg(not(any(target_os = "ios", target_os = "watchos")))] +fn repository_git_remotes(vault: &Path) -> Result>, ConfigError> { + let path = vault.join(".git/config"); + if !path.is_file() { + return Ok(None); + } + let config = + gix_config::File::from_path_no_includes(path, gix_config::Source::Local).map_err(|_| { + ConfigError::InvalidField { + field: "git.repository_remotes", + } + })?; + let mut remotes = Vec::new(); + if let Some(sections) = config.sections_by_name("remote") { + for section in sections { + let name = section + .header() + .subsection_name() + .and_then(|name| name.to_str().ok()) + .ok_or(ConfigError::InvalidField { + field: "git.repository_remotes", + })?; + let url = config + .raw_value_by("remote", Some(name.into()), "url") + .ok() + .and_then(|url| url.to_str().ok().map(str::to_owned)) + .ok_or(ConfigError::InvalidRemoteUrl { + name: name.to_owned(), + })?; + let endpoint = + RemoteEndpoint::parse(&url).map_err(|_| ConfigError::InvalidRemoteUrl { + name: name.to_owned(), + })?; + let remote = match endpoint { + RemoteEndpoint::Https(ref endpoint) => GitRemote::https( + name, + &url, + stable_identifier( + "server", + format!( + "{}://{}:{}", + endpoint.scheme(), + endpoint.host_str().unwrap_or_default(), + endpoint.port_or_known_default().unwrap_or(443) + ) + .as_bytes(), + ), + stable_identifier("repository", url.as_bytes()), + )?, + RemoteEndpoint::Ssh(_) => GitRemote::ssh(name, &url)?, + }; + remotes.push(remote); + } + } + Ok((!remotes.is_empty()).then_some(remotes)) +} + +#[cfg(not(any(target_os = "ios", target_os = "watchos")))] +fn stable_identifier(prefix: &str, value: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(value); + let suffix: String = digest[..8] + .iter() + .flat_map(|byte| [HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]]) + .map(char::from) + .collect(); + format!("{prefix}-{suffix}") +} + fn resolve_required_path( base: &Path, value: Option, diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 0e59d18..6db37bc 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -892,13 +892,11 @@ pub fn confirm_ssh_host(remote: &GitRemote, host_key: &SshHostKey) -> Result<(), .endpoint() .as_ssh() .ok_or(GitError::SshAuthenticationNotConfigured)?; - if endpoint.host() != host_key.host() || endpoint.port() != host_key.port() { + let connection = crate::ssh::resolve_connection(endpoint, remote.ssh_authentication())?; + if connection.host != host_key.host() || connection.port != host_key.port() { return Err(GitError::SshProtocolFailed); } - let authentication = remote - .ssh_authentication() - .ok_or(GitError::SshAuthenticationNotConfigured)?; - crate::ssh::persist_confirmed_host(authentication.known_hosts_file(), host_key) + crate::ssh::persist_confirmed_host(&connection.known_hosts, host_key) } pub trait GitSmartHttpTransport { diff --git a/crates/storage/src/ssh.rs b/crates/storage/src/ssh.rs index f45fecb..7367ae2 100644 --- a/crates/storage/src/ssh.rs +++ b/crates/storage/src/ssh.rs @@ -24,7 +24,10 @@ use russh::{ use sha1::Sha1; use crate::{ - config::{GitRemote, RemoteEndpoint, SshFingerprint, SshIdentitySource, SshRepositoryPath}, + config::{ + GitRemote, RemoteEndpoint, SshEndpoint, SshFingerprint, SshIdentitySource, + SshRemoteAuthentication, SshRepositoryPath, + }, git::{GitError, GitOperationControl, GitProgressPhase, SshHostKey, SshPassphraseProvider}, }; @@ -124,18 +127,16 @@ impl SshSession { let RemoteEndpoint::Ssh(endpoint) = remote.endpoint() else { return Err(GitError::ForbiddenRemoteUrl); }; - let authentication = remote - .ssh_authentication() - .ok_or(GitError::SshAuthenticationNotConfigured)?; + let connection = resolve_connection(endpoint, remote.ssh_authentication())?; control.report(GitProgressPhase::Validating)?; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|_| GitError::SshProtocolFailed)?; - let host = endpoint.host().to_owned(); - let port = endpoint.port(); - let known_hosts = authentication.known_hosts_file().to_owned(); + let host = connection.host.clone(); + let port = connection.port; + let known_hosts = connection.known_hosts.clone(); let verifier = HostVerifier { host: host.clone(), port, @@ -159,8 +160,8 @@ impl SshSession { }; control.report(GitProgressPhase::Authenticating)?; - let user = endpoint.user().unwrap_or("git"); - let authenticated = match authentication.identity() { + let user = connection.user.as_deref().unwrap_or("git"); + let authenticated = match &connection.identity { SshIdentitySource::KeyFile(path) => runtime.block_on(authenticate_key_file( &mut handle, user, @@ -289,6 +290,150 @@ impl SshSession { } } +pub(crate) struct SshConnection { + pub(crate) host: String, + pub(crate) port: u16, + user: Option, + identity: SshIdentitySource, + pub(crate) known_hosts: PathBuf, +} + +pub(crate) fn resolve_connection( + endpoint: &SshEndpoint, + authentication: Option<&SshRemoteAuthentication>, +) -> Result { + let home = home_directory().ok_or(GitError::SshAuthenticationNotConfigured)?; + let mut connection = SshConnection { + host: endpoint.host().to_owned(), + port: endpoint.port(), + user: endpoint.user().map(str::to_owned), + identity: authentication + .map(|authentication| authentication.identity().clone()) + .unwrap_or_else(|| SshIdentitySource::KeyFile(home.join(".ssh/id_ed25519"))), + known_hosts: authentication + .map(|authentication| authentication.known_hosts_file().to_owned()) + .unwrap_or_else(|| home.join(".ssh/known_hosts")), + }; + apply_openssh_config( + &home.join(".ssh/config"), + endpoint.host(), + &home, + authentication.is_none(), + &mut connection, + )?; + Ok(connection) +} + +fn home_directory() -> Option { + #[cfg(target_os = "windows")] + let home = std::env::var_os("USERPROFILE"); + #[cfg(not(target_os = "windows"))] + let home = std::env::var_os("HOME"); + home.filter(|home| !home.is_empty()).map(PathBuf::from) +} + +fn apply_openssh_config( + path: &Path, + alias: &str, + home: &Path, + use_identity: bool, + connection: &mut SshConnection, +) -> Result<(), GitError> { + let Ok(contents) = fs::read_to_string(path) else { + return Ok(()); + }; + let mut active = true; + let mut hostname_set = false; + let mut user_set = connection.user.is_some(); + let mut port_set = connection.port != 22; + let mut identity_set = !use_identity; + for line in contents.lines() { + let Some(words) = shlex::split(line.split('#').next().unwrap_or_default()) else { + return Err(GitError::SshAuthenticationNotConfigured); + }; + let Some((keyword, values)) = words.split_first() else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + active = host_patterns_match(values, alias); + } else if active && keyword.eq_ignore_ascii_case("hostname") && !hostname_set { + if let Some(value) = values.first() { + connection.host = value.clone(); + hostname_set = true; + } + } else if active && keyword.eq_ignore_ascii_case("user") && !user_set { + if let Some(value) = values.first() { + connection.user = Some(value.clone()); + user_set = true; + } + } else if active && keyword.eq_ignore_ascii_case("port") && !port_set { + if let Some(value) = values.first() { + connection.port = value + .parse() + .ok() + .filter(|port| *port != 0) + .ok_or(GitError::SshAuthenticationNotConfigured)?; + port_set = true; + } + } else if active + && keyword.eq_ignore_ascii_case("identityfile") + && !identity_set + && let Some(value) = values.first() + { + let expanded = value + .replace("%h", &connection.host) + .replace("%p", &connection.port.to_string()) + .replace("%r", connection.user.as_deref().unwrap_or("git")); + let identity = expanded + .strip_prefix("~/") + .map_or_else(|| PathBuf::from(&expanded), |path| home.join(path)); + connection.identity = SshIdentitySource::KeyFile(identity); + identity_set = true; + } + } + Ok(()) +} + +fn host_patterns_match(patterns: &[String], host: &str) -> bool { + let mut matched = false; + for pattern in patterns { + let (negated, pattern) = pattern + .strip_prefix('!') + .map_or((false, pattern.as_str()), |pattern| (true, pattern)); + if wildcard_match(pattern.as_bytes(), host.as_bytes()) { + if negated { + return false; + } + matched = true; + } + } + matched +} + +fn wildcard_match(pattern: &[u8], value: &[u8]) -> bool { + let (mut pattern_index, mut value_index, mut star, mut retry) = (0, 0, None, 0); + while value_index < value.len() { + if pattern + .get(pattern_index) + .is_some_and(|byte| *byte == b'?' || byte.eq_ignore_ascii_case(&value[value_index])) + { + pattern_index += 1; + value_index += 1; + } else if pattern.get(pattern_index) == Some(&b'*') { + star = Some(pattern_index); + pattern_index += 1; + retry = value_index; + } else if let Some(star_index) = star { + pattern_index = star_index + 1; + retry += 1; + value_index = retry; + } else { + return false; + } + } + pattern[pattern_index..].iter().all(|byte| *byte == b'*') +} + fn client_config() -> client::Config { client::Config { inactivity_timeout: Some(CONNECTION_TIMEOUT), @@ -1034,6 +1179,39 @@ mod tests { server, }; + #[test] + fn openssh_host_alias_resolves_connection_and_identity() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let config = temporary.path().join("config"); + fs::write( + &config, + "Host *\n IdentityFile ~/.ssh/id_ed25519\nHost git.example\n HostName internal.example\n User git\n Port 2222\n", + ) + .expect("SSH config"); + let mut connection = super::SshConnection { + host: "git.example".to_owned(), + port: 22, + user: None, + identity: crate::config::SshIdentitySource::KeyFile(temporary.path().join("unused")), + known_hosts: temporary.path().join("known_hosts"), + }; + super::apply_openssh_config( + &config, + "git.example", + temporary.path(), + true, + &mut connection, + ) + .expect("resolve SSH config"); + assert_eq!(connection.host, "internal.example"); + assert_eq!(connection.user.as_deref(), Some("git")); + assert_eq!(connection.port, 2222); + assert_eq!( + connection.identity.key_file(), + Some(temporary.path().join(".ssh/id_ed25519").as_path()) + ); + } + use crate::{ config::{GitRemote, RemoteEndpoint, SshFingerprint, SshRemoteAuthentication}, git::{GitError, GitOperationControl, SshPassphraseProvider}, diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 66e56a4..cf573a3 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -102,6 +102,37 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { Ok(()) } +#[cfg(not(any(target_os = "ios", target_os = "watchos")))] +#[test] +fn desktop_configuration_uses_repository_remotes_without_duplicate_settings() -> TestResult { + let fixture = ConfigurationFixture::new()?; + let vault = fixture.temporary.path().join("cwd/vault"); + fs::create_dir_all(vault.join(".git"))?; + fs::write( + vault.join(".git/config"), + "[remote \"origin\"]\n\turl = git@git.example:team/store.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", + )?; + fixture.write_explicit( + r#" +vault = "../vault" +default_key = "0123456789ABCDEF0123456789ABCDEF01234567" +key_material = "keys" +"#, + )?; + + let config = fixture + .loader() + .load(Some(Path::new("config/config.toml")))?; + assert_eq!(config.git_remotes().len(), 1); + assert_eq!(config.git_remotes()[0].name().as_str(), "origin"); + assert_eq!( + config.git_remotes()[0].url(), + "git@git.example:team/store.git" + ); + assert!(config.git_remotes()[0].ssh_authentication().is_none()); + Ok(()) +} + #[test] fn git_commit_identity_defaults_validates_and_persists() -> TestResult { let fixture = ConfigurationFixture::new()?; diff --git a/docs/configuration.md b/docs/configuration.md index 67d1351..c41937b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,8 +92,10 @@ ssh_agent_socket = "/run/user/1000/ssh-agent.socket" ``` Private-key passphrases are stored by fingerprint in the operating-system -secret store and never appear in TOML. IronStorage does not read OpenSSH -configuration, try additional keys, prompt for passwords or +secret store and never appear in TOML. Desktop builds obtain remotes from the +password-store repository and honor the connection-only `Host`, `HostName`, +`User`, `Port`, and `IdentityFile` directives in `~/.ssh/config`. IronStorage +does not try additional keys, prompt for passwords or keyboard-interactive authentication, launch an agent, or invoke proxy/helper commands. The normal CLI, TUI, and desktop builds enable the storage `ssh` feature, so the same configuration drives diff --git a/docs/git-synchronization.md b/docs/git-synchronization.md index 3a70e4b..1560c0f 100644 --- a/docs/git-synchronization.md +++ b/docs/git-synchronization.md @@ -50,13 +50,16 @@ are read with size and line bounds; exact, hashed, and bracketed non-default port entries are supported. Unknown keys require an explicit call to persist the confirmed key atomically. Changed keys always fail and are never replaced. -Authentication uses one configured OpenSSH private-key file (Ed25519, ECDSA, -or RSA) or one exact SHA-256 fingerprint from an already-running SSH agent. +Desktop applications take remote names and URLs from the password-store's +`.git/config`. Authentication uses the matching `~/.ssh/config` identity or an +explicit OpenSSH private-key file (Ed25519, ECDSA, or RSA), or one exact SHA-256 +fingerprint from an already-running SSH agent. Encrypted-key passphrases come from `SecretBytes` in the operating-system secret store. Identity attempts are bounded and deterministic; IronStorage does not spray keys, use passwords or keyboard-interactive authentication, -read OpenSSH configuration, start/probe an agent process, or run proxy/helper -commands. The explicit client allowlist excludes SHA-1 key exchange/MAC, +start/probe an agent process or run proxy/helper commands. The in-process +client reads `Host`, `HostName`, `User`, `Port`, and `IdentityFile`; executable +OpenSSH directives remain disabled. The explicit client allowlist excludes SHA-1 key exchange/MAC, RSA/SHA-1 signatures, DSA, CBC, `none`, compression, and host certificates. Cancellation interrupts connection and authentication without changing Git, known hosts, or secure storage. diff --git a/docs/ssh-transport-audit.md b/docs/ssh-transport-audit.md index a3ffe09..cddaf18 100644 --- a/docs/ssh-transport-audit.md +++ b/docs/ssh-transport-audit.md @@ -85,8 +85,9 @@ host/key interaction design. ## Deliberate OpenSSH differences -IronStorage does not read arbitrary OpenSSH configuration, `ProxyCommand`, -`Match`, URL rewrites, separate push URLs, host certificates, password or +IronStorage reads the connection-only `Host`, `HostName`, `User`, `Port`, and +`IdentityFile` directives from OpenSSH configuration. It does not support +`ProxyCommand`, `Match`, URL rewrites, separate push URLs, host certificates, password or keyboard-interactive authentication, agent forwarding, arbitrary remote commands, local transport helpers, or local-path remotes. These are explicit security boundaries, not partial implementations. HTTPS smart Git remains the