Implement SSH identity authentication
This commit is contained in:
@@ -94,3 +94,4 @@ nix = { version = "0.31", features = ["fs"] }
|
||||
rand_chacha = "0.3"
|
||||
smallvec = "1.15"
|
||||
tempfile = "3"
|
||||
tokio-stream.workspace = true
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use sha1::{Digest as _, Sha1};
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::{
|
||||
config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId},
|
||||
config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId, SshFingerprint},
|
||||
crypto::{KeyHandle, KeyStore, SecretProvider},
|
||||
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
||||
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
||||
@@ -364,6 +364,67 @@ impl GitDiffEntry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct SshHostKey {
|
||||
host: String,
|
||||
port: u16,
|
||||
algorithm: String,
|
||||
fingerprint: SshFingerprint,
|
||||
encoded: String,
|
||||
}
|
||||
|
||||
impl SshHostKey {
|
||||
#[cfg(feature = "ssh")]
|
||||
pub(crate) fn new(
|
||||
host: String,
|
||||
port: u16,
|
||||
algorithm: String,
|
||||
fingerprint: SshFingerprint,
|
||||
encoded: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
host,
|
||||
port,
|
||||
algorithm,
|
||||
fingerprint,
|
||||
encoded,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn algorithm(&self) -> &str {
|
||||
&self.algorithm
|
||||
}
|
||||
|
||||
pub const fn fingerprint(&self) -> &SshFingerprint {
|
||||
&self.fingerprint
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssh")]
|
||||
pub(crate) fn encoded(&self) -> &str {
|
||||
&self.encoded
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SshHostKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SshHostKey")
|
||||
.field("host", &self.host)
|
||||
.field("port", &self.port)
|
||||
.field("algorithm", &self.algorithm)
|
||||
.field("fingerprint", &self.fingerprint)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum GitError {
|
||||
NotRepository,
|
||||
@@ -390,6 +451,44 @@ pub enum GitError {
|
||||
RemoteNotFound {
|
||||
name: String,
|
||||
},
|
||||
SshAuthenticationNotConfigured,
|
||||
SshIdentityMissing {
|
||||
path: PathBuf,
|
||||
},
|
||||
SshIdentityInvalid {
|
||||
path: PathBuf,
|
||||
},
|
||||
SshUnsupportedAlgorithm {
|
||||
algorithm: String,
|
||||
},
|
||||
SshKeyPassphraseUnavailable {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
SshKeyPassphraseDenied {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
SshKeyPassphraseCancelled {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
SshKeyPassphraseRejected {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
SshAgentUnavailable,
|
||||
SshAgentIdentityMissing {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
SshAuthenticationRejected,
|
||||
UnknownSshHostKey {
|
||||
host_key: Box<SshHostKey>,
|
||||
},
|
||||
ChangedSshHostKey {
|
||||
host_key: Box<SshHostKey>,
|
||||
line: usize,
|
||||
},
|
||||
SshKnownHostsUnavailable {
|
||||
path: PathBuf,
|
||||
},
|
||||
SshProtocolFailed,
|
||||
CredentialsUnavailable,
|
||||
CredentialAccessDenied,
|
||||
CredentialCancelled,
|
||||
@@ -441,6 +540,73 @@ impl fmt::Display for GitError {
|
||||
)
|
||||
}
|
||||
Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
|
||||
Self::SshAuthenticationNotConfigured => {
|
||||
formatter.write_str("SSH authentication is not configured")
|
||||
}
|
||||
Self::SshIdentityMissing { path } => {
|
||||
write!(
|
||||
formatter,
|
||||
"SSH identity file was not found: {}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Self::SshIdentityInvalid { path } => {
|
||||
write!(
|
||||
formatter,
|
||||
"SSH identity file is invalid: {}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Self::SshUnsupportedAlgorithm { algorithm } => {
|
||||
write!(formatter, "unsupported SSH key algorithm: {algorithm}")
|
||||
}
|
||||
Self::SshKeyPassphraseUnavailable { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
"SSH key passphrase is unavailable for {fingerprint}"
|
||||
)
|
||||
}
|
||||
Self::SshKeyPassphraseDenied { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
"access to the SSH key passphrase was denied for {fingerprint}"
|
||||
)
|
||||
}
|
||||
Self::SshKeyPassphraseCancelled { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
"SSH key passphrase access was cancelled for {fingerprint}"
|
||||
)
|
||||
}
|
||||
Self::SshKeyPassphraseRejected { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
"SSH key passphrase was rejected for {fingerprint}"
|
||||
)
|
||||
}
|
||||
Self::SshAgentUnavailable => formatter.write_str("the SSH agent is unavailable"),
|
||||
Self::SshAgentIdentityMissing { fingerprint } => {
|
||||
write!(formatter, "the SSH agent does not contain {fingerprint}")
|
||||
}
|
||||
Self::SshAuthenticationRejected => {
|
||||
formatter.write_str("SSH public-key authentication was rejected")
|
||||
}
|
||||
Self::UnknownSshHostKey { host_key } => write!(
|
||||
formatter,
|
||||
"unknown SSH host key for {}:{} ({})",
|
||||
host_key.host, host_key.port, host_key.fingerprint
|
||||
),
|
||||
Self::ChangedSshHostKey { host_key, line } => write!(
|
||||
formatter,
|
||||
"changed SSH host key for {}:{} ({}, known_hosts line {line})",
|
||||
host_key.host, host_key.port, host_key.fingerprint
|
||||
),
|
||||
Self::SshKnownHostsUnavailable { path } => write!(
|
||||
formatter,
|
||||
"SSH known-hosts file is unavailable: {}",
|
||||
path.display()
|
||||
),
|
||||
Self::SshProtocolFailed => formatter.write_str("the SSH protocol failed"),
|
||||
Self::CredentialsUnavailable => {
|
||||
formatter.write_str("HTTPS Git credentials are unavailable")
|
||||
}
|
||||
@@ -617,6 +783,10 @@ pub trait GitCredentialProvider {
|
||||
) -> Result<GitCredential, GitError>;
|
||||
}
|
||||
|
||||
pub trait SshPassphraseProvider {
|
||||
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError>;
|
||||
}
|
||||
|
||||
pub trait GitSmartHttpTransport {
|
||||
fn advertise_receive_pack(
|
||||
&self,
|
||||
|
||||
@@ -58,6 +58,8 @@ pub mod repository;
|
||||
mod secret;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod secret_store;
|
||||
#[cfg(all(feature = "full", feature = "ssh"))]
|
||||
pub mod ssh;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod write;
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{ApplicationId, ServerId},
|
||||
config::{ApplicationId, ServerId, SshFingerprint},
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
git::{GitCredential, GitCredentialProvider, GitError},
|
||||
git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60);
|
||||
const MAX_CACHE_CAPACITY: usize = 128;
|
||||
const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase";
|
||||
const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git";
|
||||
const SSH_KEY_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.ssh-key-passphrase";
|
||||
|
||||
/// The purpose and stable, non-secret identity of an OS credential.
|
||||
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -44,6 +45,9 @@ enum SecretReferenceKind {
|
||||
application_id: String,
|
||||
account: String,
|
||||
},
|
||||
SshKeyPassphrase {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
}
|
||||
|
||||
impl SecretReference {
|
||||
@@ -87,10 +91,17 @@ impl SecretReference {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ssh_key_passphrase(fingerprint: SshFingerprint) -> Self {
|
||||
Self {
|
||||
kind: SecretReferenceKind::SshKeyPassphrase { fingerprint },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn account(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { .. } => None,
|
||||
SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account),
|
||||
SecretReferenceKind::SshKeyPassphrase { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +120,11 @@ impl SecretReference {
|
||||
server_id: server_id.clone(),
|
||||
application_id: application_id.clone(),
|
||||
},
|
||||
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
|
||||
SecretLocator::SshKeyPassphrase {
|
||||
fingerprint: fingerprint.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +138,9 @@ impl fmt::Debug for SecretReference {
|
||||
Self {
|
||||
kind: SecretReferenceKind::HttpsGitCredential { .. },
|
||||
} => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"),
|
||||
Self {
|
||||
kind: SecretReferenceKind::SshKeyPassphrase { .. },
|
||||
} => formatter.write_str("SecretReference::SshKeyPassphrase([REDACTED])"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +156,9 @@ pub enum SecretLocator {
|
||||
server_id: String,
|
||||
application_id: String,
|
||||
},
|
||||
SshKeyPassphrase {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretLocator {
|
||||
@@ -148,6 +170,9 @@ impl fmt::Debug for SecretLocator {
|
||||
Self::HttpsGitCredential { .. } => {
|
||||
formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])")
|
||||
}
|
||||
Self::SshKeyPassphrase { .. } => {
|
||||
formatter.write_str("SecretLocator::SshKeyPassphrase([REDACTED])")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +187,9 @@ impl SecretLocator {
|
||||
server_id,
|
||||
application_id,
|
||||
} => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")),
|
||||
Self::SshKeyPassphrase { fingerprint } => {
|
||||
(SSH_KEY_PASSPHRASE_SERVICE, fingerprint.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +237,7 @@ impl SecretProtectionPolicy {
|
||||
match &reference.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
||||
SecretReferenceKind::HttpsGitCredential { .. } => self.git,
|
||||
SecretReferenceKind::SshKeyPassphrase { .. } => self.git,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +245,7 @@ impl SecretProtectionPolicy {
|
||||
match locator {
|
||||
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
|
||||
SecretLocator::HttpsGitCredential { .. } => self.git,
|
||||
SecretLocator::SshKeyPassphrase { .. } => self.git,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -815,6 +845,23 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend> SshPassphraseProvider for SecretStore<B> {
|
||||
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||
self.retrieve(&SecretReference::ssh_key_passphrase(fingerprint.clone()))
|
||||
.map_err(|error| match error {
|
||||
SecretStoreError::Cancelled => GitError::SshKeyPassphraseCancelled {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
SecretStoreError::Denied => GitError::SshKeyPassphraseDenied {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
_ => GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_error(error: SecretStoreError) -> SecretProviderError {
|
||||
match error {
|
||||
SecretStoreError::Missing => SecretProviderError::Missing,
|
||||
@@ -878,6 +925,10 @@ fn encode_record(
|
||||
write_field(&mut encoded, application_id.as_bytes())?;
|
||||
write_field(&mut encoded, account.as_bytes())?;
|
||||
}
|
||||
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
|
||||
encoded.push(3);
|
||||
write_field(&mut encoded, fingerprint.as_str().as_bytes())?;
|
||||
}
|
||||
}
|
||||
let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?;
|
||||
encoded.extend_from_slice(&length.to_be_bytes());
|
||||
@@ -919,6 +970,14 @@ fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError>
|
||||
)
|
||||
.map_err(|_| SecretStoreError::Corrupted)?
|
||||
}
|
||||
3 => {
|
||||
let (fingerprint, rest) = read_field(remainder)?;
|
||||
remainder = rest;
|
||||
SecretReference::ssh_key_passphrase(
|
||||
SshFingerprint::parse(read_text(fingerprint)?)
|
||||
.map_err(|_| SecretStoreError::Corrupted)?,
|
||||
)
|
||||
}
|
||||
_ => return Err(SecretStoreError::Corrupted),
|
||||
};
|
||||
if remainder.len() < 4 {
|
||||
@@ -993,8 +1052,8 @@ fn copy_secret(value: &SecretBytes) -> SecretBytes {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION, SecretBytes,
|
||||
SecretLocator, SecretStoreError, decode_record,
|
||||
HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION,
|
||||
SSH_KEY_PASSPHRASE_SERVICE, SecretBytes, SecretLocator, SecretStoreError, decode_record,
|
||||
};
|
||||
|
||||
fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
|
||||
@@ -1033,6 +1092,12 @@ mod tests {
|
||||
server_id: "server".to_owned(),
|
||||
application_id: "application".to_owned(),
|
||||
};
|
||||
let ssh = SecretLocator::SshKeyPassphrase {
|
||||
fingerprint: crate::config::SshFingerprint::parse(
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
)
|
||||
.unwrap(),
|
||||
};
|
||||
assert_eq!(
|
||||
openpgp.service_and_user(),
|
||||
(
|
||||
@@ -1044,7 +1109,15 @@ mod tests {
|
||||
git.service_and_user(),
|
||||
(HTTPS_GIT_SERVICE, "server/application".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
ssh.service_and_user(),
|
||||
(
|
||||
SSH_KEY_PASSPHRASE_SERVICE,
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned()
|
||||
)
|
||||
);
|
||||
assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||
assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||
assert!(SSH_KEY_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||
}
|
||||
}
|
||||
|
||||
1148
crates/storage/src/ssh.rs
Normal file
1148
crates/storage/src/ssh.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -701,7 +701,7 @@ url = "https://example.test/team/store.git"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_remote_configuration_round_trips_without_https_credentials() -> TestResult {
|
||||
fn ssh_remote_configuration_round_trips_with_secret_free_authentication() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
@@ -712,12 +712,27 @@ key_material = "keys"
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "git@example.test:team/store.git"
|
||||
ssh_identity_file = "keys/id_ed25519"
|
||||
ssh_known_hosts_file = "known_hosts"
|
||||
"#,
|
||||
)?;
|
||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
let remote = &config.git_remotes()[0];
|
||||
assert_eq!(remote.url(), "git@example.test:team/store.git");
|
||||
assert!(remote.https_credentials().is_none());
|
||||
let authentication = remote.ssh_authentication().expect("SSH authentication");
|
||||
assert_eq!(
|
||||
authentication.identity().key_file(),
|
||||
Some(
|
||||
fs::canonicalize(fixture.temporary.path())?
|
||||
.join("cwd/config/keys/id_ed25519")
|
||||
.as_path()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
authentication.known_hosts_file(),
|
||||
fs::canonicalize(fixture.temporary.path())?.join("cwd/config/known_hosts")
|
||||
);
|
||||
|
||||
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
|
||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
@@ -725,6 +740,70 @@ url = "git@example.test:team/store.git"
|
||||
let persisted = fs::read_to_string(fixture.explicit_path())?;
|
||||
assert!(!persisted.contains("server_id"));
|
||||
assert!(!persisted.contains("application_id"));
|
||||
assert!(!persisted.contains("passphrase"));
|
||||
assert!(persisted.contains("ssh_identity_file"));
|
||||
assert!(persisted.contains("ssh_known_hosts_file"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_authentication_requires_exactly_one_identity_source() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "vault"
|
||||
default_key = "alice"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "ssh://git@example.test/team/store.git"
|
||||
"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.loader()
|
||||
.load(Some(&fixture.explicit_path()))
|
||||
.expect_err("SSH identity is required"),
|
||||
ConfigError::InvalidField {
|
||||
field: "git.remotes.ssh_authentication"
|
||||
}
|
||||
);
|
||||
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "vault"
|
||||
default_key = "alice"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "ssh://git@example.test/team/store.git"
|
||||
ssh_agent_fingerprint = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
ssh_agent_socket = "agent.sock"
|
||||
ssh_known_hosts_file = "known_hosts"
|
||||
"#,
|
||||
)?;
|
||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
let authentication = config.git_remotes()[0]
|
||||
.ssh_authentication()
|
||||
.expect("SSH authentication");
|
||||
assert_eq!(
|
||||
authentication
|
||||
.identity()
|
||||
.agent_fingerprint()
|
||||
.expect("agent fingerprint")
|
||||
.as_str(),
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
);
|
||||
assert_eq!(
|
||||
authentication.identity().agent_socket(),
|
||||
Some(
|
||||
fs::canonicalize(fixture.temporary.path())?
|
||||
.join("cwd/config/agent.sock")
|
||||
.as_path()
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ use std::{
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
config::{ConfigLoader, GitRemote},
|
||||
config::{ConfigLoader, GitRemote, SshFingerprint},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
|
||||
git::{GitCredentialProvider as _, GitError},
|
||||
git::{GitCredentialProvider as _, GitError, SshPassphraseProvider as _},
|
||||
repository::{EncryptedEntry, SecretBytes},
|
||||
secret_store::{
|
||||
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator,
|
||||
@@ -274,6 +274,41 @@ fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestRe
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
let store = store(backend.clone());
|
||||
store.unlock()?;
|
||||
let fingerprint = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
|
||||
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
|
||||
store.create(
|
||||
&reference,
|
||||
SecretBytes::new(b"protected-passphrase".to_vec()),
|
||||
)?;
|
||||
assert_eq!(
|
||||
store.ssh_key_passphrase(&fingerprint)?.expose(),
|
||||
b"protected-passphrase"
|
||||
);
|
||||
backend.fail_next(SecretStoreError::Denied);
|
||||
assert!(matches!(
|
||||
store.ssh_key_passphrase(&fingerprint),
|
||||
Err(GitError::SshKeyPassphraseDenied { fingerprint: denied }) if denied == fingerprint
|
||||
));
|
||||
backend.fail_next(SecretStoreError::Cancelled);
|
||||
assert!(matches!(
|
||||
store.ssh_key_passphrase(&fingerprint),
|
||||
Err(GitError::SshKeyPassphraseCancelled { fingerprint: cancelled })
|
||||
if cancelled == fingerprint
|
||||
));
|
||||
let missing = SshFingerprint::parse("SHA256:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE")?;
|
||||
assert!(matches!(
|
||||
store.ssh_key_passphrase(&missing),
|
||||
Err(GitError::SshKeyPassphraseUnavailable { fingerprint }) if fingerprint == missing
|
||||
));
|
||||
assert!(!format!("{reference:?}").contains("protected-passphrase"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
|
||||
Reference in New Issue
Block a user