Implement SSH identity authentication
This commit is contained in:
@@ -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",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user