Use repository Git remotes and SSH config
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled

This commit is contained in:
Georg Bauer
2026-08-26 09:32:21 +02:00
parent a3da9fda69
commit 300ccf5f1f
7 changed files with 316 additions and 23 deletions

View File

@@ -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<Option<Vec<GitRemote>>, 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<PathBuf>,

View File

@@ -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 {

View File

@@ -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<String>,
identity: SshIdentitySource,
pub(crate) known_hosts: PathBuf,
}
pub(crate) fn resolve_connection(
endpoint: &SshEndpoint,
authentication: Option<&SshRemoteAuthentication>,
) -> Result<SshConnection, GitError> {
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<PathBuf> {
#[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},

View File

@@ -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()?;

View File

@@ -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

View File

@@ -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.

View File

@@ -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