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

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