Implement SSH identity authentication

This commit is contained in:
2026-08-25 19:52:47 +02:00
parent f636f3b551
commit 5dbda4bbd2
14 changed files with 1964 additions and 60 deletions

View File

@@ -422,7 +422,7 @@ impl Config {
Some(remote) => {
git.insert(
"remotes".to_owned(),
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]),
);
}
None => {
@@ -497,7 +497,7 @@ impl Config {
let mut git = toml::Table::new();
git.insert(
"remotes".to_owned(),
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]),
);
root.insert("git".to_owned(), toml::Value::Table(git));
}
@@ -734,7 +734,7 @@ impl Config {
}
}
fn git_remote_document(remote: &GitRemote) -> toml::Table {
fn git_remote_document(remote: &GitRemote) -> Result<toml::Table, ConfigError> {
let mut configured = toml::Table::new();
configured.insert(
"name".to_owned(),
@@ -754,7 +754,39 @@ fn git_remote_document(remote: &GitRemote) -> toml::Table {
toml::Value::String(application_id.as_str().to_owned()),
);
}
configured
if let Some(authentication) = remote.ssh_authentication() {
match authentication.identity() {
SshIdentitySource::KeyFile(path) => {
configured.insert(
"ssh_identity_file".to_owned(),
toml::Value::String(path_text(path, "git.remotes.ssh_identity_file")?),
);
}
SshIdentitySource::Agent {
fingerprint,
socket,
} => {
configured.insert(
"ssh_agent_fingerprint".to_owned(),
toml::Value::String(fingerprint.to_string()),
);
if let Some(socket) = socket {
configured.insert(
"ssh_agent_socket".to_owned(),
toml::Value::String(path_text(socket, "git.remotes.ssh_agent_socket")?),
);
}
}
}
configured.insert(
"ssh_known_hosts_file".to_owned(),
toml::Value::String(path_text(
authentication.known_hosts_file(),
"git.remotes.ssh_known_hosts_file",
)?),
);
}
Ok(configured)
}
/// Deterministic path context for configuration loading.
@@ -940,6 +972,114 @@ pub struct SshEndpoint {
path: SshRepositoryPath,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct SshFingerprint(String);
impl SshFingerprint {
pub fn parse(value: impl Into<String>) -> Result<Self, InvalidRemoteEndpoint> {
let value = value.into();
let encoded = value.strip_prefix("SHA256:").ok_or(InvalidRemoteEndpoint)?;
if encoded.len() != 43 {
return Err(InvalidRemoteEndpoint);
}
let digest = data_encoding::BASE64_NOPAD
.decode(encoded.as_bytes())
.map_err(|_| InvalidRemoteEndpoint)?;
if digest.len() != 32 {
return Err(InvalidRemoteEndpoint);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SshFingerprint {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SshIdentitySource {
KeyFile(PathBuf),
Agent {
fingerprint: SshFingerprint,
socket: Option<PathBuf>,
},
}
impl SshIdentitySource {
pub fn key_file(&self) -> Option<&Path> {
match self {
Self::KeyFile(path) => Some(path),
Self::Agent { .. } => None,
}
}
pub fn agent_fingerprint(&self) -> Option<&SshFingerprint> {
match self {
Self::KeyFile(_) => None,
Self::Agent { fingerprint, .. } => Some(fingerprint),
}
}
pub fn agent_socket(&self) -> Option<&Path> {
match self {
Self::Agent { socket, .. } => socket.as_deref(),
Self::KeyFile(_) => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SshRemoteAuthentication {
identity: SshIdentitySource,
known_hosts_file: PathBuf,
}
impl SshRemoteAuthentication {
pub fn key_file(
identity_file: PathBuf,
known_hosts_file: PathBuf,
) -> Result<Self, ConfigError> {
validate_standalone_ssh_path(&identity_file, "git.remotes.ssh_identity_file")?;
validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?;
Ok(Self {
identity: SshIdentitySource::KeyFile(identity_file),
known_hosts_file,
})
}
pub fn agent(
fingerprint: SshFingerprint,
socket: Option<PathBuf>,
known_hosts_file: PathBuf,
) -> Result<Self, ConfigError> {
if let Some(socket) = &socket {
validate_standalone_ssh_path(socket, "git.remotes.ssh_agent_socket")?;
}
validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?;
Ok(Self {
identity: SshIdentitySource::Agent {
fingerprint,
socket,
},
known_hosts_file,
})
}
pub const fn identity(&self) -> &SshIdentitySource {
&self.identity
}
pub fn known_hosts_file(&self) -> &Path {
&self.known_hosts_file
}
}
impl SshEndpoint {
pub fn user(&self) -> Option<&str> {
self.user.as_deref()
@@ -1122,7 +1262,7 @@ enum GitRemoteCredentials {
server_id: ServerId,
application_id: ApplicationId,
},
Ssh,
Ssh(Option<SshRemoteAuthentication>),
}
impl GitRemote {
@@ -1132,25 +1272,49 @@ impl GitRemote {
server_id: impl Into<String>,
application_id: impl Into<String>,
) -> Result<Self, ConfigError> {
let mut remotes = validate_remotes(vec![RawGitRemote {
name: name.into(),
url: url.into(),
server_id: Some(server_id.into()),
application_id: Some(application_id.into()),
}])?;
let mut remotes = validate_remotes(
vec![RawGitRemote {
name: name.into(),
url: url.into(),
server_id: Some(server_id.into()),
application_id: Some(application_id.into()),
ssh_identity_file: None,
ssh_agent_fingerprint: None,
ssh_agent_socket: None,
ssh_known_hosts_file: None,
}],
None,
)?;
Ok(remotes.remove(0))
}
pub fn ssh(name: impl Into<String>, url: impl Into<String>) -> Result<Self, ConfigError> {
let mut remotes = validate_remotes(vec![RawGitRemote {
name: name.into(),
url: url.into(),
server_id: None,
application_id: None,
}])?;
let mut remotes = validate_remotes(
vec![RawGitRemote {
name: name.into(),
url: url.into(),
server_id: None,
application_id: None,
ssh_identity_file: None,
ssh_agent_fingerprint: None,
ssh_agent_socket: None,
ssh_known_hosts_file: None,
}],
None,
)?;
Ok(remotes.remove(0))
}
pub fn ssh_with_authentication(
name: impl Into<String>,
url: impl Into<String>,
authentication: SshRemoteAuthentication,
) -> Result<Self, ConfigError> {
let mut remote = Self::ssh(name, url)?;
remote.credentials = GitRemoteCredentials::Ssh(Some(authentication));
Ok(remote)
}
pub fn name(&self) -> &RemoteName {
&self.name
}
@@ -1169,7 +1333,14 @@ impl GitRemote {
server_id,
application_id,
} => Some((server_id, application_id)),
GitRemoteCredentials::Ssh => None,
GitRemoteCredentials::Ssh(_) => None,
}
}
pub const fn ssh_authentication(&self) -> Option<&SshRemoteAuthentication> {
match &self.credentials {
GitRemoteCredentials::Ssh(authentication) => authentication.as_ref(),
GitRemoteCredentials::Https { .. } => None,
}
}
}
@@ -1412,6 +1583,10 @@ struct RawGitRemote {
url: String,
server_id: Option<String>,
application_id: Option<String>,
ssh_identity_file: Option<PathBuf>,
ssh_agent_fingerprint: Option<String>,
ssh_agent_socket: Option<PathBuf>,
ssh_known_hosts_file: Option<PathBuf>,
}
fn validate_config(
@@ -1512,7 +1687,7 @@ fn validate_config(
});
}
};
let git_remotes = validate_remotes(raw.git.remotes)?;
let git_remotes = validate_remotes(raw.git.remotes, Some(base))?;
Ok(Config {
source,
@@ -1664,7 +1839,10 @@ fn parse_environment_editor(
.map_err(|()| EditorError::InvalidCommand { source: variable })
}
fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigError> {
fn validate_remotes(
raw: Vec<RawGitRemote>,
config_base: Option<&Path>,
) -> Result<Vec<GitRemote>, ConfigError> {
let mut names = BTreeSet::new();
let mut references = BTreeSet::new();
let mut remotes = Vec::with_capacity(raw.len());
@@ -1679,6 +1857,15 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
name: name.0.clone(),
})?;
let has_ssh_fields = remote.ssh_identity_file.is_some()
|| remote.ssh_agent_fingerprint.is_some()
|| remote.ssh_agent_socket.is_some()
|| remote.ssh_known_hosts_file.is_some();
if matches!(&endpoint, RemoteEndpoint::Https(_)) && has_ssh_fields {
return Err(ConfigError::InvalidField {
field: "git.remotes.ssh_authentication",
});
}
let credentials = match (&endpoint, remote.server_id, remote.application_id) {
(RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => {
let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?);
@@ -1704,7 +1891,60 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
field: "git.remotes.application_id",
});
}
(RemoteEndpoint::Ssh(_), None, None) => GitRemoteCredentials::Ssh,
(RemoteEndpoint::Ssh(_), None, None) => {
let Some(base) = config_base else {
if has_ssh_fields {
return Err(ConfigError::InvalidField {
field: "git.remotes.ssh_authentication",
});
}
remotes.push(GitRemote {
name,
url: remote.url,
endpoint,
credentials: GitRemoteCredentials::Ssh(None),
});
continue;
};
let known_hosts_file = match remote.ssh_known_hosts_file {
Some(path) => resolve_ssh_path(base, path, "git.remotes.ssh_known_hosts_file")?,
None => default_known_hosts_path().ok_or(ConfigError::InvalidField {
field: "git.remotes.ssh_known_hosts_file",
})?,
};
let identity = match (
remote.ssh_identity_file,
remote.ssh_agent_fingerprint,
remote.ssh_agent_socket,
) {
(Some(path), None, None) => SshIdentitySource::KeyFile(resolve_ssh_path(
base,
path,
"git.remotes.ssh_identity_file",
)?),
(None, Some(fingerprint), socket) => SshIdentitySource::Agent {
fingerprint: SshFingerprint::parse(fingerprint).map_err(|_| {
ConfigError::InvalidField {
field: "git.remotes.ssh_agent_fingerprint",
}
})?,
socket: socket
.map(|path| {
resolve_ssh_path(base, path, "git.remotes.ssh_agent_socket")
})
.transpose()?,
},
_ => {
return Err(ConfigError::InvalidField {
field: "git.remotes.ssh_authentication",
});
}
};
GitRemoteCredentials::Ssh(Some(SshRemoteAuthentication {
identity,
known_hosts_file,
}))
}
(RemoteEndpoint::Ssh(_), _, _) => {
return Err(ConfigError::InvalidField {
field: "git.remotes.https_credentials",
@@ -1721,6 +1961,43 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
Ok(remotes)
}
fn resolve_ssh_path(
base: &Path,
value: PathBuf,
field: &'static str,
) -> Result<PathBuf, ConfigError> {
if value.as_os_str().is_empty() || path_text(&value, field)?.chars().any(char::is_control) {
return Err(ConfigError::InvalidField { field });
}
Ok(resolve_path(base, &value))
}
fn validate_standalone_ssh_path(path: &Path, field: &'static str) -> Result<(), ConfigError> {
if !path.is_absolute()
|| path.as_os_str().is_empty()
|| path_text(path, field)?.chars().any(char::is_control)
{
return Err(ConfigError::InvalidField { field });
}
Ok(())
}
#[cfg(target_os = "windows")]
fn default_known_hosts_path() -> Option<PathBuf> {
env::var_os("USERPROFILE")
.filter(|home| !home.is_empty() && Path::new(home).is_absolute())
.map(PathBuf::from)
.map(|home| home.join(".ssh").join("known_hosts"))
}
#[cfg(not(target_os = "windows"))]
fn default_known_hosts_path() -> Option<PathBuf> {
env::var_os("HOME")
.filter(|home| !home.is_empty() && Path::new(home).is_absolute())
.map(PathBuf::from)
.map(|home| home.join(".ssh").join("known_hosts"))
}
fn validate_identifier(field: &'static str, value: String) -> Result<String, ConfigError> {
if value.is_empty()
|| value.len() > 128
@@ -1794,7 +2071,16 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
validate_table(
remote,
&format!("git.remotes[{index}]"),
&["name", "url", "server_id", "application_id"],
&[
"name",
"url",
"server_id",
"application_id",
"ssh_identity_file",
"ssh_agent_fingerprint",
"ssh_agent_socket",
"ssh_known_hosts_file",
],
)?;
}
}