Enable SSH remotes in native frontends (#117)

This commit is contained in:
hugo committed 2026-08-25 21:39:35 +02:00
1 parent 76e707f645
commit a737e74aae
22 files changed
+1214 -140

No files matched your search

+131 -3
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);