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