Enable SSH remotes in native frontends (#117)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user