Enable SSH remotes in native frontends (#117)

This commit is contained in:
2026-08-25 21:39:35 +02:00
parent 76e707f645
commit a737e74aae
22 changed files with 1214 additions and 140 deletions

View File

@@ -14,7 +14,7 @@ path = "src/main.rs"
[dependencies]
ctrlc.workspace = true
ironstorage.workspace = true
ironstorage = { workspace = true, features = ["ssh"] }
rpassword.workspace = true
tempfile = "3"

View File

@@ -22,10 +22,13 @@ use ironstorage::{
InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text,
otp_version_text, parse_from, version_text,
},
config::Config,
config::{Config, GitRemote, SshFingerprint},
crypto::KeyStore,
generate::{GeneratorConfig, PasswordGenerator},
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome},
git::{
GitChangeKind, GitError, GitIdentity, GitRemoteCredentialOverride,
GitRemoteCredentialProvider, GitRepository, PullOutcome,
},
kdbx::KdbxImporter,
mutation::{
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
@@ -718,7 +721,7 @@ fn execute_secure_with_services<
Some(configured) => configured,
None => {
stderr
.write_all(b"the requested HTTPS Git remote is not configured\n")
.write_all(b"the requested Git remote is not configured\n")
.map_err(|_| ())?;
return Ok(EXIT_CONFIG);
}
@@ -727,30 +730,86 @@ fn execute_secure_with_services<
Ok(git) => git,
Err(error) => return operation_error(stderr, error),
};
let result = match request {
GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None),
GitRequest::Pull { branch, .. } => git
.pull(configured, branch.as_deref(), secrets)
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())),
GitRequest::Push { branch, .. } => git
.push(configured, branch.as_deref(), secrets)
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))),
GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| {
Some(format!(
"{}; {} {}",
pull_outcome_name(pull),
push.remote(),
push.new_id()
))
}),
_ => unreachable!("local Git requests do not require secret access"),
let mut supplied_passphrase = None;
let mut confirmed_host = false;
let result = loop {
let credentials = supplied_passphrase.as_ref().map_or_else(
|| GitRemoteCredentialOverride::new(&*secrets),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
&*secrets,
fingerprint,
passphrase,
)
},
);
match run_remote_git(&git, configured, request, &credentials) {
Err(GitError::UnknownSshHostKey { host_key }) if !confirmed_host => {
let decision = interaction.confirm(
&format!(
"Trust SSH host {}:{} {} {}?",
host_key.host(),
host_key.port(),
host_key.algorithm(),
host_key.fingerprint()
),
stderr,
);
match decision {
Ok(OverwriteDecision::Allow) => {
if let Err(error) =
ironstorage::git::confirm_ssh_host(configured, &host_key)
{
break Err(error);
}
confirmed_host = true;
}
Ok(OverwriteDecision::Decline) => break Err(GitError::Cancelled),
Err(_) => break Err(GitError::Cancelled),
}
}
Err(GitError::SshKeyPassphraseUnavailable { fingerprint })
if supplied_passphrase.is_none() =>
{
let passphrase = match interaction.read_ssh_passphrase(&fingerprint) {
Ok(passphrase) => passphrase,
Err(_) => {
break Err(GitError::SshKeyPassphraseCancelled { fingerprint });
}
};
supplied_passphrase = Some((fingerprint, passphrase));
}
result => break result,
}
};
match result {
Ok(Some(message)) => {
if let Some((fingerprint, passphrase)) = supplied_passphrase
&& let Err(error) =
secrets.persist_verified_ssh_passphrase(&fingerprint, passphrase)
{
writeln!(
stderr,
"warning: SSH succeeded, but the verified key passphrase could not be stored: {error}"
)
.map_err(|_| ())?;
}
writeln!(stdout, "{message}").map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
Ok(None) => Ok(EXIT_SUCCESS),
Ok(None) => {
if let Some((fingerprint, passphrase)) = supplied_passphrase
&& let Err(error) =
secrets.persist_verified_ssh_passphrase(&fingerprint, passphrase)
{
writeln!(
stderr,
"warning: SSH succeeded, but the verified key passphrase could not be stored: {error}"
)
.map_err(|_| ())?;
}
Ok(EXIT_SUCCESS)
}
Err(error) => operation_error(stderr, error),
}
}
@@ -1068,6 +1127,11 @@ trait CliInteraction: OtpInteraction {
plaintext: &SecretBytes,
config: &Config,
) -> Result<(SecretBytes, String), CliInteractionError>;
fn read_ssh_passphrase(
&mut self,
fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError>;
}
struct NativeOtpInteraction;
@@ -1217,6 +1281,18 @@ impl CliInteraction for NativeOtpInteraction {
.map_err(CliInteractionError::Editor)?;
Ok((replacement, name))
}
fn read_ssh_passphrase(
&mut self,
fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError> {
if !std::io::stdin().is_terminal() {
return Err(CliInteractionError::Input);
}
rpassword::prompt_password(format!("Enter SSH key passphrase for {fingerprint}: "))
.map(|value| SecretBytes::new(value.into_bytes()))
.map_err(|_| CliInteractionError::Input)
}
}
#[cfg(test)]
@@ -1269,6 +1345,13 @@ impl CliInteraction for UnavailableOtpInteraction {
) -> Result<(SecretBytes, String), CliInteractionError> {
Err(CliInteractionError::Input)
}
fn read_ssh_passphrase(
&mut self,
_fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError> {
Err(CliInteractionError::Input)
}
}
#[derive(Debug)]
@@ -1548,6 +1631,32 @@ fn select_remote<'a>(
}
}
fn run_remote_git(
git: &GitRepository,
configured: &GitRemote,
request: &GitRequest,
credentials: &impl GitRemoteCredentialProvider,
) -> Result<Option<String>, GitError> {
match request {
GitRequest::Fetch { .. } => git.fetch(configured, credentials).map(|_| None),
GitRequest::Pull { branch, .. } => git
.pull(configured, branch.as_deref(), credentials)
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())),
GitRequest::Push { branch, .. } => git
.push(configured, branch.as_deref(), credentials)
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))),
GitRequest::Sync { .. } => git.sync(configured, credentials).map(|(pull, push)| {
Some(format!(
"{}; {} {}",
pull_outcome_name(pull),
push.remote(),
push.new_id()
))
}),
_ => unreachable!("local Git requests do not require secret access"),
}
}
fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
writeln!(stderr, "{error}").map_err(|_| ())?;
Ok(EXIT_FAILURE)
@@ -1652,6 +1761,7 @@ mod tests {
insert_inputs: VecDeque<ironstorage::write::InsertContent>,
edit_replacements: VecDeque<SecretBytes>,
kdbx_passwords: VecDeque<SecretBytes>,
ssh_passphrases: VecDeque<SecretBytes>,
decisions: VecDeque<OverwriteDecision>,
plans: Vec<InputPlan>,
prompts: Vec<String>,
@@ -1715,6 +1825,15 @@ mod tests {
.map(|replacement| (replacement, "fixture-editor".to_owned()))
.ok_or(super::CliInteractionError::Input)
}
fn read_ssh_passphrase(
&mut self,
_fingerprint: &super::SshFingerprint,
) -> Result<SecretBytes, super::CliInteractionError> {
self.ssh_passphrases
.pop_front()
.ok_or(super::CliInteractionError::Input)
}
}
impl CliPresentation for MemoryPresentation {
@@ -1747,6 +1866,39 @@ mod tests {
}
}
#[test]
fn cli_selects_typed_ssh_remotes_and_keeps_prompted_passphrases_redacted() -> TestResult {
let temporary = tempfile::tempdir()?;
fs::create_dir(temporary.path().join("vault"))?;
fs::create_dir(temporary.path().join("keys"))?;
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n[[git.remotes]]\nname = 'scp'\nurl = 'git@example.test:team/store.git'\nssh_agent_fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\nssh_agent_socket = 'agent.sock'\n[[git.remotes]]\nname = 'uri'\nurl = 'ssh://git@example.test/team/store.git'\nssh_agent_fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\nssh_agent_socket = 'agent.sock'\n",
)?;
let config = Config::load(Some(&config_path))?;
for name in ["scp", "uri"] {
let remote = super::select_remote(&config, Some(name)).expect("configured SSH remote");
assert_eq!(
remote.endpoint().transport(),
ironstorage::config::RemoteTransport::Ssh
);
}
let fingerprint =
super::SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
let mut interaction = MemoryOtpInteraction::default();
interaction
.ssh_passphrases
.push_back(SecretBytes::new(b"terminal secret".to_vec()));
assert!(!format!("{:?}", interaction.ssh_passphrases).contains("terminal secret"));
assert_eq!(
super::CliInteraction::read_ssh_passphrase(&mut interaction, &fingerprint)?.expose(),
b"terminal secret"
);
Ok(())
}
impl SecretStoreBackend for MemoryBackend {
fn create(
&self,