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,

View File

@@ -29,7 +29,7 @@ path = "src/main.rs"
[dependencies]
iced.workspace = true
ironstorage.workspace = true
ironstorage = { workspace = true, features = ["ssh"] }
zeroize.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]

View File

@@ -37,6 +37,7 @@ use ironstorage::{
NativeAuthenticationHandle, NativeAuthenticationSession,
},
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
config::SshFingerprint,
crypto::KeyInfo,
desktop::{
DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult,
@@ -50,7 +51,7 @@ use ironstorage::{
generate::GeneratorConfig,
git::{
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
GitProgressPhase, GitSnapshot,
GitProgressPhase, GitSnapshot, SshHostKey,
},
kdbx::{KdbxImportMode, KdbxImportRequest},
mutation::{MutationAction, MutationOutcome, MutationSelection},
@@ -160,6 +161,9 @@ enum Message {
result: Box<Result<MutationOutcome, String>>,
},
RunGit(DesktopGitRequest),
GitPassphraseChanged(Zeroizing<String>),
SubmitGitInteraction,
CancelGitInteraction,
ChooseGitConflict(usize, GitConflictChoice),
ResolveGitConflicts,
CancelGit,
@@ -521,13 +525,55 @@ struct GitConflictSelection {
choice: Option<GitConflictChoice>,
}
#[derive(Clone, Debug, Default)]
enum GitInteraction {
ConfirmHost {
request: DesktopGitRequest,
host_key: SshHostKey,
},
Passphrase {
request: DesktopGitRequest,
fingerprint: SshFingerprint,
value: Zeroizing<String>,
},
}
impl std::fmt::Debug for GitInteraction {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConfirmHost { request, host_key } => formatter
.debug_struct("ConfirmHost")
.field("request", request)
.field("host_key", host_key)
.finish(),
Self::Passphrase {
request,
fingerprint,
..
} => formatter
.debug_struct("Passphrase")
.field("request", request)
.field("fingerprint", fingerprint)
.field("value", &"[REDACTED]")
.finish(),
}
}
}
#[derive(Debug, Default)]
struct GitForm {
snapshot: Option<GitSnapshot>,
progress: Option<GitProgressPhase>,
running: bool,
error: Option<String>,
conflicts: Vec<GitConflictSelection>,
active_request: Option<DesktopGitRequest>,
interaction: Option<GitInteraction>,
}
enum GitRetry {
Normal,
ConfirmHost(SshHostKey),
Passphrase(SshFingerprint, SecretBytes),
}
impl GitForm {
@@ -1674,6 +1720,56 @@ impl App {
self.execute_action(pending)
};
}
Message::GitPassphraseChanged(value) => {
if let Some(UtilityView::Git(GitForm {
interaction: Some(GitInteraction::Passphrase { value: current, .. }),
running: false,
..
})) = &mut self.utility
{
*current = value;
}
}
Message::SubmitGitInteraction => {
let interaction = match &mut self.utility {
Some(UtilityView::Git(form)) if !form.running => form.interaction.take(),
_ => None,
};
match interaction {
Some(GitInteraction::ConfirmHost { request, host_key }) => {
return self.begin_git_retry(request, GitRetry::ConfirmHost(host_key));
}
Some(GitInteraction::Passphrase {
request,
fingerprint,
value,
}) if !value.is_empty() => {
return self.begin_git_retry(
request,
GitRetry::Passphrase(
fingerprint,
SecretBytes::new(value.as_bytes().to_vec()),
),
);
}
Some(interaction) => {
if let Some(UtilityView::Git(form)) = &mut self.utility {
form.interaction = Some(interaction);
form.error = Some("Enter the SSH key passphrase.".to_owned());
}
}
None => {}
}
}
Message::CancelGitInteraction => {
if let Some(UtilityView::Git(form)) = &mut self.utility
&& !form.running
&& form.interaction.take().is_some()
{
form.error = None;
self.status = "SSH authentication cancelled; repository unchanged.".to_owned();
}
}
Message::ChooseGitConflict(index, choice) => {
if let Some(UtilityView::Git(form)) = &mut self.utility
&& !form.running
@@ -1739,10 +1835,34 @@ impl App {
form.running = false;
form.error = None;
form.conflicts.clear();
form.active_request = None;
form.interaction = None;
}
self.status = git_outcome_message(&outcome);
}
Err(error) => {
let interaction = if let Some(UtilityView::Git(form)) = &mut self.utility {
let request = form.active_request.take();
match (request, error.git_error()) {
(Some(request), Some(GitError::UnknownSshHostKey { host_key })) => {
Some(GitInteraction::ConfirmHost {
request,
host_key: (**host_key).clone(),
})
}
(
Some(request),
Some(GitError::SshKeyPassphraseUnavailable { fingerprint }),
) => Some(GitInteraction::Passphrase {
request,
fingerprint: fingerprint.clone(),
value: Zeroizing::new(String::new()),
}),
_ => None,
}
} else {
None
};
let conflicts = error
.conflicts()
.iter()
@@ -1756,10 +1876,27 @@ impl App {
if let Some(UtilityView::Git(form)) = &mut self.utility {
form.progress = None;
form.running = false;
form.error = Some(message.clone());
form.error = interaction.is_none().then(|| message.clone());
form.conflicts = conflicts;
form.interaction = interaction;
}
self.status = if error.kind() == DesktopErrorKind::Conflict {
self.status = if matches!(
&self.utility,
Some(UtilityView::Git(GitForm {
interaction: Some(GitInteraction::ConfirmHost { .. }),
..
}))
) {
"Confirm the unknown SSH host fingerprint before retrying.".to_owned()
} else if matches!(
&self.utility,
Some(UtilityView::Git(GitForm {
interaction: Some(GitInteraction::Passphrase { .. }),
..
}))
) {
"Enter the encrypted SSH key passphrase to retry.".to_owned()
} else if error.kind() == DesktopErrorKind::Conflict {
format!("Git requires explicit conflict resolution: {message}")
} else {
format!("Git operation failed: {message}")
@@ -3004,6 +3141,10 @@ impl App {
}
fn begin_git(&mut self, request: DesktopGitRequest) -> Task<Message> {
self.begin_git_retry(request, GitRetry::Normal)
}
fn begin_git_retry(&mut self, request: DesktopGitRequest, retry: GitRetry) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
return Task::none();
};
@@ -3027,15 +3168,34 @@ impl App {
form.progress = Some(GitProgressPhase::Validating);
form.running = true;
form.error = None;
form.interaction = None;
form.active_request = Some(request.clone());
}
self.status = format!("Git {}", git_request_name(&request));
Task::perform(
async move {
Arc::new(Mutex::new(Some(storage.git_operation(
handle.as_ref(),
&request,
&control,
))))
let result = match retry {
GitRetry::Normal => storage.git_operation(handle.as_ref(), &request, &control),
GitRetry::ConfirmHost(host_key) => storage
.confirm_ssh_host(&host_key)
.and_then(|()| storage.git_operation(handle.as_ref(), &request, &control)),
GitRetry::Passphrase(fingerprint, passphrase) => {
let result = storage.git_operation_with_ssh_passphrase(
handle.as_ref(),
&request,
&control,
Some((&fingerprint, &passphrase)),
);
if result.is_ok()
&& let Some(handle) = handle.as_ref()
{
let _ =
handle.persist_verified_ssh_passphrase(&fingerprint, passphrase);
}
result
}
};
Arc::new(Mutex::new(Some(result)))
},
move |completion| Message::GitFinished {
generation,
@@ -4641,7 +4801,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
}
UtilityView::Git(form) => {
content = content.push(text(
"All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.",
"All repository, transport, credential, host-trust, merge, and conflict decisions are owned by crates/storage. No git, SSH, or credential-helper process is launched.",
));
if let Some(phase) = form.progress {
content = content.push(text(format!("Progress: {}", git_phase_name(phase))));
@@ -4649,20 +4809,57 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
if let Some(error) = &form.error {
content = content.push(text(format!("Git error: {error}")));
}
match &form.interaction {
Some(GitInteraction::ConfirmHost { host_key, .. }) => {
content = content
.push(text("Unknown SSH host key").size(20))
.push(text(format!(
"Host: {}:{}",
host_key.host(),
host_key.port()
)))
.push(text(format!("Algorithm: {}", host_key.algorithm())))
.push(text(format!("Fingerprint: {}", host_key.fingerprint())))
.push(text(
"Verify this fingerprint through a trusted channel. Confirmation appends only this observed key; changed keys cannot be confirmed here.",
));
}
Some(GitInteraction::Passphrase {
fingerprint, value, ..
}) => {
content = content
.push(text("Encrypted SSH private key").size(20))
.push(text(format!("Key fingerprint: {fingerprint}")))
.push(
text_input("SSH key passphrase", value)
.secure(true)
.on_input(|value| {
Message::GitPassphraseChanged(Zeroizing::new(value))
})
.on_submit(Message::SubmitGitInteraction)
.style(entry_input_style),
)
.push(text(
"The passphrase is supplied to crates/storage for this retry and is never shown, logged, copied, or added to command history.",
));
}
None => {}
}
if let Some(snapshot) = &form.snapshot {
content = content
.push(text(format!("Repository: {}", snapshot.root().display())))
.push(text(format!("Branch: {}", snapshot.branch())));
if let Some(remote) = snapshot.remote() {
content = content.push(text(format!(
"HTTPS remote: {} · {} · {} ahead / {} behind",
"{} remote: {} · {} · {} ahead / {} behind",
remote.transport(),
remote.name(),
remote.url(),
remote.ahead(),
remote.behind()
)));
} else {
content = content.push(text("No HTTPS remote is configured."));
content = content.push(text("No Git remote is configured."));
}
let status = snapshot.status();
content = content.push(text(if status.is_clean() {
@@ -4915,7 +5112,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
))
.push(text("Git actions").size(22))
.push(text(
"Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation. Remotes are HTTPS-only; conflicts require an explicit choice and are never silently discarded.",
"Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation for configured HTTPS and SSH remotes. Unknown SSH hosts show their fingerprint for explicit confirmation; changed keys cannot be bypassed. Encrypted key passphrases use masked input. Conflicts require an explicit choice and are never silently discarded.",
))
.push(text("OTP actions").size(22))
.push(text(
@@ -4992,6 +5189,16 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
UtilityView::Git(form) => {
if form.running {
row![utility_button("Cancel Git operation").on_press(Message::CancelGit)]
} else if let Some(interaction) = &form.interaction {
row![
utility_button(match interaction {
GitInteraction::ConfirmHost { .. } => "Trust host and retry",
GitInteraction::Passphrase { .. } => "Unlock key and retry",
})
.on_press(Message::SubmitGitInteraction),
utility_button("Cancel").on_press(Message::CancelGitInteraction),
]
.spacing(8)
} else {
let mut actions = row![
utility_icon_control(
@@ -5878,8 +6085,8 @@ fn git_request_name(request: &DesktopGitRequest) -> &'static str {
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase {
GitProgressPhase::Validating => "validating the repository and HTTPS remote",
GitProgressPhase::Authenticating => "requesting secure HTTPS credentials",
GitProgressPhase::Validating => "validating the repository and remote",
GitProgressPhase::Authenticating => "verifying remote trust and credentials",
GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects",
@@ -5927,11 +6134,52 @@ fn git_failure_message(error: &DesktopError) -> String {
"No embedded Git repository exists for this password store.".to_owned()
}
Some(GitError::ForbiddenRemoteUrl) => {
"The repository remote must match the configured credential-free HTTPS URL. Fix the shared configuration or repository remote, then refresh.".to_owned()
"The repository remote must match the configured credential-free Git URL. Fix the shared configuration or repository remote, then refresh.".to_owned()
}
Some(GitError::RemoteNotFound { name }) => {
format!("The configured HTTPS remote {name} is missing from the repository.")
format!("The configured Git remote {name} is missing from the repository.")
}
Some(GitError::UnsupportedRemoteTransport { transport }) => {
format!("This build does not include the {transport} Git transport.")
}
Some(GitError::SshAuthenticationNotConfigured) => {
"The SSH remote needs one configured identity file or exact agent fingerprint."
.to_owned()
}
Some(GitError::SshIdentityMissing { path }) => {
format!("The configured SSH identity file is missing: {}.", path.display())
}
Some(GitError::SshIdentityInvalid { path }) => {
format!("The configured SSH identity file is invalid: {}.", path.display())
}
Some(GitError::SshKeyPassphraseDenied { .. }) => {
"Access to the stored SSH key passphrase was denied.".to_owned()
}
Some(GitError::SshKeyPassphraseCancelled { .. }) => {
"The SSH key passphrase request was cancelled.".to_owned()
}
Some(GitError::SshKeyPassphraseRejected { .. }) => {
"The supplied SSH key passphrase did not unlock the configured key.".to_owned()
}
Some(GitError::SshAgentUnavailable) => {
"The configured SSH agent socket is unavailable.".to_owned()
}
Some(GitError::SshAgentIdentityMissing { fingerprint }) => {
format!("The SSH agent does not contain the configured identity {fingerprint}.")
}
Some(GitError::SshAuthenticationRejected) => {
"The SSH server rejected the configured public-key identity.".to_owned()
}
Some(GitError::ChangedSshHostKey { host_key, line }) => format!(
"SSH host key changed for {}:{} ({} at known-hosts line {line}). This cannot be bypassed; verify and repair known hosts outside this prompt.",
host_key.host(),
host_key.port(),
host_key.fingerprint()
),
Some(GitError::SshKnownHostsUnavailable { path }) => format!(
"The SSH known-hosts file is unavailable: {}.",
path.display()
),
Some(GitError::CredentialsUnavailable) => {
"HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned()
}
@@ -5945,7 +6193,7 @@ fn git_failure_message(error: &DesktopError) -> String {
"The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned()
}
Some(GitError::NetworkUnavailable) => {
"The HTTPS Git server is unreachable; check the network and retry.".to_owned()
"The Git server is unreachable; check the network and retry.".to_owned()
}
Some(GitError::TlsFailed) => {
"TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned()
@@ -7534,7 +7782,7 @@ mod tests {
}
#[test]
fn desktop_git_status_is_typed_https_only_and_cancellation_safe() {
fn desktop_git_status_is_transport_typed_and_cancellation_safe() {
let (temporary, _initial) = fixture_storage();
let config_path = temporary.path().join("config.toml");
let mut config = fs::read_to_string(&config_path).expect("configuration");
@@ -7564,6 +7812,10 @@ mod tests {
result.snapshot().remote().expect("remote").url(),
"https://example.test/store.git"
);
assert_eq!(
result.snapshot().remote().expect("remote").transport(),
ironstorage::config::RemoteTransport::Https
);
assert_eq!(
*phases.lock().expect("progress"),
[GitProgressPhase::Validating]
@@ -7596,7 +7848,7 @@ mod tests {
&DesktopGitRequest::Refresh,
&GitOperationControl::default(),
)
.expect_err("non-HTTPS remote rejection");
.expect_err("configured remote mismatch");
assert_eq!(error.kind(), DesktopErrorKind::Git);
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
assert_eq!(
@@ -7609,6 +7861,24 @@ mod tests {
assert!(!DesktopGitRequest::Push.changes_worktree());
}
#[test]
fn desktop_ssh_passphrase_state_never_renders_or_debugs_secret_text() {
let fingerprint =
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.expect("fingerprint");
let form = GitForm {
interaction: Some(GitInteraction::Passphrase {
request: DesktopGitRequest::Sync,
fingerprint: fingerprint.clone(),
value: Zeroizing::new("desktop secret".to_owned()),
}),
..GitForm::default()
};
let debug = format!("{form:?}");
assert!(debug.contains(fingerprint.as_str()));
assert!(!debug.contains("desktop secret"));
}
#[test]
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
let (_temporary, storage) = fixture_storage();

View File

@@ -14,7 +14,7 @@ path = "src/main.rs"
[dependencies]
crossterm.workspace = true
ironstorage.workspace = true
ironstorage = { workspace = true, features = ["ssh"] }
ratatui.workspace = true
zeroize.workspace = true

View File

@@ -4,13 +4,14 @@ use std::{collections::BTreeSet, time::Instant};
use ironstorage::{
command::{
CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
CommandRequest, GitRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
Presentation, help_text, otp_version_text, version_text,
},
config::Config,
config::SshFingerprint,
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot},
git::{GitConflict, GitProgressPhase, GitSnapshot, SshHostKey},
otp::OtpCodeValidity,
presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
read::{FindResults, GrepResults, TreeModel},
@@ -223,6 +224,14 @@ pub enum AsyncPayload {
conflicts: Vec<GitConflict>,
details: Option<SecretBytes>,
},
GitHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
GitPassphraseRequired {
request: GitRequest,
fingerprint: SshFingerprint,
},
OtpCodeFinished {
entry: String,
field: Option<EntryFieldId>,
@@ -301,6 +310,15 @@ pub enum AppEffect {
},
AuthenticateWorkflow(Box<WorkflowSubmission>),
AuthenticateGit(ironstorage::command::GitRequest),
RetryGitAfterHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
RetryGitWithPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
AuthenticateOtp(OtpUiRequest),
@@ -536,6 +554,14 @@ impl App {
self.status = format!("{label} queued for secure-storage authentication…");
}
fn begin_git_retry(&mut self) {
self.git_pending = true;
self.workflow_pending = false;
self.workflow = None;
self.transition(Transition::Dismiss);
self.status = "Retrying Git operation with confirmed SSH input…".to_owned();
}
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease
}
@@ -861,6 +887,26 @@ impl App {
self.focus = PaneFocus::Main;
self.status = message;
}
Ok(AsyncPayload::GitHostConfirmation { request, host_key }) => {
self.git_pending = false;
self.workflow = Some(WorkflowForm::ssh_host(request, host_key));
self.workflow_pending = false;
self.status =
"Verify the SSH host fingerprint through a trusted channel before confirming."
.to_owned();
self.transition(Transition::OpenDialog);
}
Ok(AsyncPayload::GitPassphraseRequired {
request,
fingerprint,
}) => {
self.git_pending = false;
self.workflow = Some(WorkflowForm::ssh_passphrase(request, fingerprint));
self.workflow_pending = false;
self.status =
"Enter the encrypted SSH key passphrase; input stays masked.".to_owned();
self.transition(Transition::OpenDialog);
}
Ok(AsyncPayload::OtpCodeFinished {
entry,
field,
@@ -1337,6 +1383,22 @@ impl App {
}
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
Ok(WorkflowSubmission::SshHost { request, host_key }) => {
self.begin_git_retry();
Some(AppEffect::RetryGitAfterHostConfirmation { request, host_key })
}
Ok(WorkflowSubmission::SshPassphrase {
request,
fingerprint,
passphrase,
}) => {
self.begin_git_retry();
Some(AppEffect::RetryGitWithPassphrase {
request,
fingerprint,
passphrase,
})
}
Ok(submission) => {
self.workflow_pending = true;
self.status = "Authenticating before applying the workflow…".to_owned();
@@ -2126,7 +2188,7 @@ impl App {
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase {
GitProgressPhase::Validating => "validating repository",
GitProgressPhase::Authenticating => "requesting credentials",
GitProgressPhase::Authenticating => "verifying remote and credentials",
GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects",
@@ -2744,4 +2806,40 @@ mod tests {
assert!(rows.contains("••••••••"));
assert!(!rows.contains("NEVER-RENDER"));
}
#[test]
fn typed_ssh_passphrase_request_opens_a_masked_cancellable_dialog() {
let mut app = App::new();
app.git_pending = true;
let token = app.begin_request();
let fingerprint =
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.expect("fingerprint");
assert_eq!(
app.apply_result(AsyncResult {
token,
payload: Ok(AsyncPayload::GitPassphraseRequired {
request: GitRequest::Sync {
remote: Some("origin".to_owned()),
},
fingerprint: fingerprint.clone(),
}),
}),
ResultDisposition::Applied
);
assert_eq!(app.mode(), Mode::Dialog);
assert!(!app.git_pending());
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
assert!(rows.contains(fingerprint.as_str()));
assert!(!rows.contains("secret"));
app.handle_workflow_input(
crossterm::event::KeyCode::Char('Q'),
crossterm::event::KeyModifiers::NONE,
);
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
assert!(rows.contains("••••••••"));
assert!(!rows.contains('Q'));
assert!(matches!(app.dispatch(Action::Cancel), AppEffect::None));
assert_eq!(app.mode(), Mode::Browser);
}
}

View File

@@ -390,6 +390,57 @@ fn apply_app_effect(
);
}
}
AppEffect::RetryGitAfterHostConfirmation { request, host_key } => {
let (Some(config), Some(handle)) = (
app.config().cloned(),
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
let remote = config
.git_remote(git_requested_remote(&request))
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
ironstorage::git::confirm_ssh_host(remote, &host_key)
.map_err(|error| error.to_string())?;
execute_git(&config, request, Some(handle), None, &control)
});
}
AppEffect::RetryGitWithPassphrase {
request,
fingerprint,
passphrase,
} => {
let (Some(config), Some(handle)) = (
app.config().cloned(),
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
execute_git(
&config,
request,
Some(handle),
Some((fingerprint, passphrase)),
&control,
)
});
}
AppEffect::AuthenticateOtp(request) => {
if let Some(coordinator) = authentication.as_mut() {
coordinator.request_otp(request);
@@ -557,7 +608,9 @@ fn apply_app_effect(
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || execute_git(&config, request, None, &control));
executor.submit(token, move || {
execute_git(&config, request, None, None, &control)
});
}
}
AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
@@ -623,15 +676,72 @@ fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bo
)
}
fn git_requested_remote(request: &ironstorage::command::GitRequest) -> Option<&str> {
match request {
ironstorage::command::GitRequest::Fetch { remote }
| ironstorage::command::GitRequest::Pull { remote, .. }
| ironstorage::command::GitRequest::Push { remote, .. }
| ironstorage::command::GitRequest::Sync { remote } => remote.as_deref(),
_ => None,
}
}
fn remote_credentials<'a>(
handle: &'a ironstorage::authentication::NativeAuthenticationHandle,
supplied: Option<&'a (
ironstorage::config::SshFingerprint,
ironstorage::repository::SecretBytes,
)>,
) -> ironstorage::git::GitRemoteCredentialOverride<
'a,
ironstorage::authentication::NativeAuthenticationHandle,
> {
supplied.map_or_else(
|| ironstorage::git::GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
ironstorage::git::GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
)
}
fn git_interaction_or_error(
request: ironstorage::command::GitRequest,
error: ironstorage::git::GitError,
) -> Result<AsyncPayload, String> {
match error {
ironstorage::git::GitError::UnknownSshHostKey { host_key } => {
Ok(AsyncPayload::GitHostConfirmation {
request,
host_key: *host_key,
})
}
ironstorage::git::GitError::SshKeyPassphraseUnavailable { fingerprint } => {
Ok(AsyncPayload::GitPassphraseRequired {
request,
fingerprint,
})
}
error => Err(error.to_string()),
}
}
fn execute_git(
config: &ironstorage::config::Config,
request: ironstorage::command::GitRequest,
mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
ssh_passphrase: Option<(
ironstorage::config::SshFingerprint,
ironstorage::repository::SecretBytes,
)>,
control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> {
use ironstorage::{
command::{GitConfigRequest, GitRemoteRequest, GitRequest},
git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport},
git::{GitError, GitIdentity, GitRepository},
repository::{Repository, SecretBytes},
};
@@ -744,20 +854,20 @@ fn execute_git(
}
},
GitRequest::Fetch { remote } => {
let retry = GitRequest::Fetch {
remote: remote.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git fetch requires authentication".to_owned())?;
let outcome = git
.fetch_with_transport_controlled(
configured,
handle,
&EmbeddedFetchTransport,
control,
)
.map_err(|error| error.to_string())?;
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
let outcome = match git.fetch_controlled(configured, &provider, control) {
Ok(outcome) => outcome,
Err(error) => return git_interaction_or_error(retry, error),
};
format!(
"Fetched {}{}",
outcome.remote(),
@@ -769,19 +879,18 @@ fn execute_git(
)
}
GitRequest::Pull { remote, branch } => {
let retry = GitRequest::Pull {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git pull requires authentication".to_owned())?;
match git.pull_with_transport_controlled(
configured,
branch.as_deref(),
handle,
&EmbeddedFetchTransport,
control,
) {
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
match git.pull_controlled(configured, branch.as_deref(), &provider, control) {
Ok(outcome) => {
changed_snapshot = true;
format!("Git pull completed: {outcome:?}")
@@ -801,25 +910,26 @@ fn execute_git(
details: None,
});
}
Err(error) => return Err(error.to_string()),
Err(error) => return git_interaction_or_error(retry, error),
}
}
GitRequest::Push { remote, branch } => {
let retry = GitRequest::Push {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git push requires authentication".to_owned())?;
let outcome = git
.push_with_transport_controlled(
configured,
branch.as_deref(),
handle,
&ReqwestGitTransport,
control,
)
.map_err(|error| error.to_string())?;
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
let outcome =
match git.push_controlled(configured, branch.as_deref(), &provider, control) {
Ok(outcome) => outcome,
Err(error) => return git_interaction_or_error(retry, error),
};
format!(
"Pushed {} {}",
outcome.remote(),
@@ -827,19 +937,17 @@ fn execute_git(
)
}
GitRequest::Sync { remote } => {
let retry = GitRequest::Sync {
remote: remote.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
let pull = match git.pull_with_transport_controlled(
configured,
None,
handle,
&EmbeddedFetchTransport,
control,
) {
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
let (pull, push) = match git.sync_controlled(configured, &provider, control) {
Ok(outcome) => outcome,
Err(GitError::MergeConflicts { conflicts }) => {
let snapshot = git
@@ -856,17 +964,8 @@ fn execute_git(
details: None,
});
}
Err(error) => return Err(error.to_string()),
Err(error) => return git_interaction_or_error(retry, error),
};
let push = git
.push_with_transport_controlled(
configured,
None,
handle,
&ReqwestGitTransport,
control,
)
.map_err(|error| error.to_string())?;
changed_snapshot = true;
format!(
"Git synchronization completed: {pull:?}; pushed {}",
@@ -874,6 +973,11 @@ fn execute_git(
)
}
};
if let Some((fingerprint, passphrase)) = ssh_passphrase
&& let Some(handle) = credentials.as_ref()
{
let _ = handle.persist_verified_ssh_passphrase(&fingerprint, passphrase);
}
control
.report(ironstorage::git::GitProgressPhase::Refreshing)
.map_err(|error| error.to_string())?;
@@ -906,7 +1010,7 @@ fn execute_git_resolution(
.map_err(|error| error.to_string())?;
let configured = config
.git_remote(None)
.ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?;
.ok_or_else(|| "no Git remote is configured".to_owned())?;
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
@@ -1117,7 +1221,7 @@ fn apply_authentication_event(
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
execute_git(&config, request, Some(handle), &control)
execute_git(&config, request, Some(handle), None, &control)
});
}
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
@@ -1203,6 +1307,9 @@ fn execute_workflow(
let mut presentation = None;
let mut refresh_tree = true;
let (entry, mut status) = match submission {
WorkflowSubmission::SshHost { .. } | WorkflowSubmission::SshPassphrase { .. } => {
unreachable!("SSH interactions are routed before storage workflows")
}
WorkflowSubmission::Init(request) => {
let directory = request.path.as_deref().unwrap_or_default();
let mut committer =
@@ -1597,6 +1704,37 @@ mod tests {
}
}
#[test]
fn typed_ssh_authentication_errors_only_prompt_for_missing_passphrases() {
let fingerprint = ironstorage::config::SshFingerprint::parse(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
.expect("fingerprint");
let request = ironstorage::command::GitRequest::Fetch {
remote: Some("origin".to_owned()),
};
assert!(matches!(
git_interaction_or_error(
request.clone(),
ironstorage::git::GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
},
),
Ok(AsyncPayload::GitPassphraseRequired {
request: prompted,
fingerprint: prompted_fingerprint,
}) if prompted == request && prompted_fingerprint == fingerprint
));
assert_eq!(
git_interaction_or_error(
request,
ironstorage::git::GitError::SshAuthenticationRejected,
)
.expect_err("authentication rejection is not a passphrase prompt"),
"SSH public-key authentication was rejected"
);
}
#[test]
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
let mut app = App::new();

View File

@@ -974,7 +974,8 @@ fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
];
if let Some(remote) = snapshot.remote() {
lines.push(Line::raw(format!(
"remote: {} {}",
"remote: {} {} {}",
remote.transport(),
remote.name(),
remote.url()
)));

View File

@@ -5,11 +5,13 @@ use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest,
RemoveRequest,
CopyRequest, GenerateRequest, GeneratedPresentation, GitRequest, GrepRequest, InitRequest,
InsertInput, InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource,
OtpInsertRequest, RemoveRequest,
},
config::SshFingerprint,
crypto::KeyInfo,
git::SshHostKey,
kdbx::{KdbxImportMode, KdbxImportRequest},
otp::OtpInput,
repository::SecretBytes,
@@ -167,6 +169,20 @@ pub struct OtpForm {
focus: usize,
}
#[derive(Debug)]
pub struct SshHostForm {
request: GitRequest,
host_key: SshHostKey,
confirmed: bool,
}
#[derive(Debug)]
pub struct SshPassphraseForm {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretText,
}
#[derive(Debug)]
pub enum WorkflowForm {
Init(InitForm),
@@ -178,6 +194,8 @@ pub enum WorkflowForm {
Move(TransferForm),
Copy(TransferForm),
Otp(OtpForm),
SshHost(SshHostForm),
SshPassphrase(SshPassphraseForm),
}
#[derive(Debug)]
@@ -217,6 +235,15 @@ pub enum WorkflowSubmission {
OtpValidate {
uri: OtpInput,
},
SshHost {
request: GitRequest,
host_key: SshHostKey,
},
SshPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -228,6 +255,22 @@ pub enum WorkflowInput {
}
impl WorkflowForm {
pub fn ssh_host(request: GitRequest, host_key: SshHostKey) -> Self {
Self::SshHost(SshHostForm {
request,
host_key,
confirmed: false,
})
}
pub fn ssh_passphrase(request: GitRequest, fingerprint: SshFingerprint) -> Self {
Self::SshPassphrase(SshPassphraseForm {
request,
fingerprint,
passphrase: SecretText::default(),
})
}
pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
Self::Otp(OtpForm {
kind,
@@ -409,6 +452,8 @@ impl WorkflowForm {
OtpFormKind::Append => "Append OTP URI",
OtpFormKind::Validate => "Validate OTP URI",
},
Self::SshHost(_) => "Confirm SSH host key",
Self::SshPassphrase(_) => "Unlock SSH private key",
}
}
@@ -579,6 +624,28 @@ impl WorkflowForm {
),
]
}
Self::SshHost(form) => vec![
row(
false,
"Host",
&format!("{}:{}", form.host_key.host(), form.host_key.port()),
),
row(false, "Algorithm", form.host_key.algorithm()),
row(false, "Fingerprint", form.host_key.fingerprint().as_str()),
row(true, "Trust this observed key", yes_no(form.confirmed)),
],
Self::SshPassphrase(form) => vec![
row(false, "Key fingerprint", form.fingerprint.as_str()),
row(
true,
"Passphrase",
if form.passphrase.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
],
}
}
@@ -789,6 +856,25 @@ impl WorkflowForm {
}
}
}
Self::SshHost(form) => {
if !form.confirmed {
return Err("Explicitly confirm the verified SSH host fingerprint".to_owned());
}
Ok(WorkflowSubmission::SshHost {
request: form.request.clone(),
host_key: form.host_key.clone(),
})
}
Self::SshPassphrase(form) => {
if form.passphrase.is_empty() {
return Err("SSH key passphrase is required".to_owned());
}
Ok(WorkflowSubmission::SshPassphrase {
request: form.request.clone(),
fingerprint: form.fingerprint.clone(),
passphrase: SecretBytes::new(form.passphrase.bytes()),
})
}
}
}
@@ -801,6 +887,7 @@ impl WorkflowForm {
Self::Kdbx(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
Self::Otp(_) => 4,
Self::SshHost(_) | Self::SshPassphrase(_) => 1,
}
}
@@ -815,6 +902,7 @@ impl WorkflowForm {
Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus,
Self::Otp(form) => &mut form.focus,
Self::SshHost(_) | Self::SshPassphrase(_) => return,
};
*focus = if forward {
(*focus + 1) % count
@@ -863,6 +951,7 @@ impl WorkflowForm {
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
Self::Otp(form) if form.focus == 2 => form.force ^= true,
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
Self::SshHost(form) => form.confirmed ^= true,
_ => self.character(' '),
}
}
@@ -919,6 +1008,7 @@ impl WorkflowForm {
Self::Otp(form) if form.focus == 1 => {
form.uri.pop();
}
Self::SshPassphrase(form) => form.passphrase.pop(),
_ => {}
}
}
@@ -954,6 +1044,7 @@ impl WorkflowForm {
}
Self::Otp(form) if form.focus == 0 => form.entry.push(character),
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character),
Self::SshPassphrase(form) if character != '\n' => form.passphrase.push(character),
_ => {}
}
}
@@ -1052,6 +1143,43 @@ mod tests {
}
}
#[test]
fn ssh_passphrase_prompt_is_masked_and_returns_typed_secret_input() {
let fingerprint = ironstorage::config::SshFingerprint::parse(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
.expect("fingerprint");
let mut form = WorkflowForm::ssh_passphrase(
ironstorage::command::GitRequest::Sync {
remote: Some("origin".to_owned()),
},
fingerprint.clone(),
);
type_text(&mut form, "terminal secret");
let rendered = format!("{:?} {:?}", form.title(), form.rows());
assert!(rendered.contains(fingerprint.as_str()));
assert!(rendered.contains("••••••••"));
assert!(!rendered.contains("terminal secret"));
let debug = format!("{form:?}");
assert!(!debug.contains("terminal secret"));
let WorkflowSubmission::SshPassphrase {
request,
fingerprint: submitted,
passphrase,
} = form.submission().expect("submission")
else {
panic!("SSH passphrase submission")
};
assert_eq!(
request,
ironstorage::command::GitRequest::Sync {
remote: Some("origin".to_owned())
}
);
assert_eq!(submitted, fingerprint);
assert_eq!(passphrase.expose(), b"terminal secret");
}
#[test]
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
let mut form = WorkflowForm::insert(None);