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

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