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

@@ -94,3 +94,4 @@ nix = { version = "0.31", features = ["fs"] }
rand_chacha = "0.3"
smallvec = "1.15"
tempfile = "3"
tokio-stream.workspace = true

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",
],
)?;
}
}

View 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,

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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(())
}

View File

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