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

@@ -15,7 +15,9 @@ generate, remove, move/copy, and Git-backed commit and synchronization flows.
Remote endpoints are typed in the storage crate as credential-free HTTPS or Remote endpoints are typed in the storage crate as credential-free HTTPS or
feature-gated SSH. HTTPS synchronization uses server/application credentials feature-gated SSH. HTTPS synchronization uses server/application credentials
kept in the operating system's secure store; SSH transport dependencies and kept in the operating system's secure store; SSH transport dependencies and
runtime are compiled only with the optional `ssh` Cargo feature. runtime are compiled only with the optional `ssh` Cargo feature. The standard
CLI, TUI, and desktop applications enable it; the Apple bridge keeps it
disabled unless an Apple target explicitly opts in.
The central `ironstorage` Rust crate owns repository access, Git, The central `ironstorage` Rust crate owns repository access, Git,
GPG-compatible encryption and key handling, entries, OTP, synchronization, GPG-compatible encryption and key handling, entries, OTP, synchronization,
@@ -135,6 +137,8 @@ The first bundle command writes `target/release/IronStorage.app`. The install
form replaces `~/Applications/IronStorage.app` with the freshly built bundle. form replaces `~/Applications/IronStorage.app` with the freshly built bundle.
The bundle contains the matching `ironstorage` CLI and `ironstorage-tui` binaries; The bundle contains the matching `ironstorage` CLI and `ironstorage-tui` binaries;
Settings can install links to them in `~/.local/bin`. Settings can install links to them in `~/.local/bin`.
All three bundled applications support configured HTTPS, `ssh://`, and scp-like
SSH remotes through the same storage-owned transport selection.
Build only the shared storage library or the Rust Apple bridge: Build only the shared storage library or the Rust Apple bridge:

View File

@@ -22,7 +22,10 @@ actually exercised.
The following are intentionally outside this milestone and must not be stubbed The following are intentionally outside this milestone and must not be stubbed
into the Apple apps: full CLI/TUI parity, command mode or command palette, SSH into the Apple apps: full CLI/TUI parity, command mode or command palette, SSH
Git, non-TOTP Watch features, and production AutoFill behavior. Git UI, non-TOTP Watch features, and production AutoFill behavior. The Apple
Rust bridge explicitly compiles storage without the optional `ssh` feature;
typed SSH configuration therefore fails as unsupported before any connection
until a future Apple target opts in.
## Security boundary audit ## Security boundary audit
@@ -31,7 +34,8 @@ Git, non-TOTP Watch features, and production AutoFill behavior.
- The Apple Rust crates are mechanical UniFFI projections. Swift may collect - The Apple Rust crates are mechanical UniFFI projections. Swift may collect
input and call those APIs; it does not open a password-store repository, input and call those APIs; it does not open a password-store repository,
calculate OTP, parse snapshots, or launch processes. calculate OTP, parse snapshots, or launch processes.
- Git URLs are rejected unless they use HTTPS before transport work. The Watch - The shipped Apple bridge rejects non-HTTPS Git transport before connection;
the shared typed storage model may still parse SSH configuration. The Watch
stores only the selected opaque snapshot in a passcode-protected, device-only stores only the selected opaque snapshot in a passcode-protected, device-only
Keychain item. Keychain item.
- Apple production sources contain no logging calls. Secret values use typed - Apple production sources contain no logging calls. Secret values use typed

View File

@@ -14,7 +14,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
ctrlc.workspace = true ctrlc.workspace = true
ironstorage.workspace = true ironstorage = { workspace = true, features = ["ssh"] }
rpassword.workspace = true rpassword.workspace = true
tempfile = "3" tempfile = "3"

View File

@@ -22,10 +22,13 @@ use ironstorage::{
InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text, InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text,
otp_version_text, parse_from, version_text, otp_version_text, parse_from, version_text,
}, },
config::Config, config::{Config, GitRemote, SshFingerprint},
crypto::KeyStore, crypto::KeyStore,
generate::{GeneratorConfig, PasswordGenerator}, generate::{GeneratorConfig, PasswordGenerator},
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome}, git::{
GitChangeKind, GitError, GitIdentity, GitRemoteCredentialOverride,
GitRemoteCredentialProvider, GitRepository, PullOutcome,
},
kdbx::KdbxImporter, kdbx::KdbxImporter,
mutation::{ mutation::{
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
@@ -718,7 +721,7 @@ fn execute_secure_with_services<
Some(configured) => configured, Some(configured) => configured,
None => { None => {
stderr 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(|_| ())?; .map_err(|_| ())?;
return Ok(EXIT_CONFIG); return Ok(EXIT_CONFIG);
} }
@@ -727,30 +730,86 @@ fn execute_secure_with_services<
Ok(git) => git, Ok(git) => git,
Err(error) => return operation_error(stderr, error), Err(error) => return operation_error(stderr, error),
}; };
let result = match request { let mut supplied_passphrase = None;
GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None), let mut confirmed_host = false;
GitRequest::Pull { branch, .. } => git let result = loop {
.pull(configured, branch.as_deref(), secrets) let credentials = supplied_passphrase.as_ref().map_or_else(
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())), || GitRemoteCredentialOverride::new(&*secrets),
GitRequest::Push { branch, .. } => git |(fingerprint, passphrase)| {
.push(configured, branch.as_deref(), secrets) GitRemoteCredentialOverride::with_ssh_passphrase(
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))), &*secrets,
GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| { fingerprint,
Some(format!( passphrase,
"{}; {} {}", )
pull_outcome_name(pull), },
push.remote(), );
push.new_id() match run_remote_git(&git, configured, request, &credentials) {
)) Err(GitError::UnknownSshHostKey { host_key }) if !confirmed_host => {
}), let decision = interaction.confirm(
_ => unreachable!("local Git requests do not require secret access"), &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 { match result {
Ok(Some(message)) => { 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(|_| ())?; writeln!(stdout, "{message}").map_err(|_| ())?;
Ok(EXIT_SUCCESS) 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), Err(error) => operation_error(stderr, error),
} }
} }
@@ -1068,6 +1127,11 @@ trait CliInteraction: OtpInteraction {
plaintext: &SecretBytes, plaintext: &SecretBytes,
config: &Config, config: &Config,
) -> Result<(SecretBytes, String), CliInteractionError>; ) -> Result<(SecretBytes, String), CliInteractionError>;
fn read_ssh_passphrase(
&mut self,
fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError>;
} }
struct NativeOtpInteraction; struct NativeOtpInteraction;
@@ -1217,6 +1281,18 @@ impl CliInteraction for NativeOtpInteraction {
.map_err(CliInteractionError::Editor)?; .map_err(CliInteractionError::Editor)?;
Ok((replacement, name)) 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)] #[cfg(test)]
@@ -1269,6 +1345,13 @@ impl CliInteraction for UnavailableOtpInteraction {
) -> Result<(SecretBytes, String), CliInteractionError> { ) -> Result<(SecretBytes, String), CliInteractionError> {
Err(CliInteractionError::Input) Err(CliInteractionError::Input)
} }
fn read_ssh_passphrase(
&mut self,
_fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError> {
Err(CliInteractionError::Input)
}
} }
#[derive(Debug)] #[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, ()> { fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
writeln!(stderr, "{error}").map_err(|_| ())?; writeln!(stderr, "{error}").map_err(|_| ())?;
Ok(EXIT_FAILURE) Ok(EXIT_FAILURE)
@@ -1652,6 +1761,7 @@ mod tests {
insert_inputs: VecDeque<ironstorage::write::InsertContent>, insert_inputs: VecDeque<ironstorage::write::InsertContent>,
edit_replacements: VecDeque<SecretBytes>, edit_replacements: VecDeque<SecretBytes>,
kdbx_passwords: VecDeque<SecretBytes>, kdbx_passwords: VecDeque<SecretBytes>,
ssh_passphrases: VecDeque<SecretBytes>,
decisions: VecDeque<OverwriteDecision>, decisions: VecDeque<OverwriteDecision>,
plans: Vec<InputPlan>, plans: Vec<InputPlan>,
prompts: Vec<String>, prompts: Vec<String>,
@@ -1715,6 +1825,15 @@ mod tests {
.map(|replacement| (replacement, "fixture-editor".to_owned())) .map(|replacement| (replacement, "fixture-editor".to_owned()))
.ok_or(super::CliInteractionError::Input) .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 { 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 { impl SecretStoreBackend for MemoryBackend {
fn create( fn create(
&self, &self,

View File

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

View File

@@ -37,6 +37,7 @@ use ironstorage::{
NativeAuthenticationHandle, NativeAuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession,
}, },
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest}, command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
config::SshFingerprint,
crypto::KeyInfo, crypto::KeyInfo,
desktop::{ desktop::{
DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult, DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult,
@@ -50,7 +51,7 @@ use ironstorage::{
generate::GeneratorConfig, generate::GeneratorConfig,
git::{ git::{
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl, GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
GitProgressPhase, GitSnapshot, GitProgressPhase, GitSnapshot, SshHostKey,
}, },
kdbx::{KdbxImportMode, KdbxImportRequest}, kdbx::{KdbxImportMode, KdbxImportRequest},
mutation::{MutationAction, MutationOutcome, MutationSelection}, mutation::{MutationAction, MutationOutcome, MutationSelection},
@@ -160,6 +161,9 @@ enum Message {
result: Box<Result<MutationOutcome, String>>, result: Box<Result<MutationOutcome, String>>,
}, },
RunGit(DesktopGitRequest), RunGit(DesktopGitRequest),
GitPassphraseChanged(Zeroizing<String>),
SubmitGitInteraction,
CancelGitInteraction,
ChooseGitConflict(usize, GitConflictChoice), ChooseGitConflict(usize, GitConflictChoice),
ResolveGitConflicts, ResolveGitConflicts,
CancelGit, CancelGit,
@@ -521,13 +525,55 @@ struct GitConflictSelection {
choice: Option<GitConflictChoice>, 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 { struct GitForm {
snapshot: Option<GitSnapshot>, snapshot: Option<GitSnapshot>,
progress: Option<GitProgressPhase>, progress: Option<GitProgressPhase>,
running: bool, running: bool,
error: Option<String>, error: Option<String>,
conflicts: Vec<GitConflictSelection>, conflicts: Vec<GitConflictSelection>,
active_request: Option<DesktopGitRequest>,
interaction: Option<GitInteraction>,
}
enum GitRetry {
Normal,
ConfirmHost(SshHostKey),
Passphrase(SshFingerprint, SecretBytes),
} }
impl GitForm { impl GitForm {
@@ -1674,6 +1720,56 @@ impl App {
self.execute_action(pending) 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) => { Message::ChooseGitConflict(index, choice) => {
if let Some(UtilityView::Git(form)) = &mut self.utility if let Some(UtilityView::Git(form)) = &mut self.utility
&& !form.running && !form.running
@@ -1739,10 +1835,34 @@ impl App {
form.running = false; form.running = false;
form.error = None; form.error = None;
form.conflicts.clear(); form.conflicts.clear();
form.active_request = None;
form.interaction = None;
} }
self.status = git_outcome_message(&outcome); self.status = git_outcome_message(&outcome);
} }
Err(error) => { 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 let conflicts = error
.conflicts() .conflicts()
.iter() .iter()
@@ -1756,10 +1876,27 @@ impl App {
if let Some(UtilityView::Git(form)) = &mut self.utility { if let Some(UtilityView::Git(form)) = &mut self.utility {
form.progress = None; form.progress = None;
form.running = false; form.running = false;
form.error = Some(message.clone()); form.error = interaction.is_none().then(|| message.clone());
form.conflicts = conflicts; 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}") format!("Git requires explicit conflict resolution: {message}")
} else { } else {
format!("Git operation failed: {message}") format!("Git operation failed: {message}")
@@ -3004,6 +3141,10 @@ impl App {
} }
fn begin_git(&mut self, request: DesktopGitRequest) -> Task<Message> { 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 { let Some(storage) = self.storage.clone() else {
return Task::none(); return Task::none();
}; };
@@ -3027,15 +3168,34 @@ impl App {
form.progress = Some(GitProgressPhase::Validating); form.progress = Some(GitProgressPhase::Validating);
form.running = true; form.running = true;
form.error = None; form.error = None;
form.interaction = None;
form.active_request = Some(request.clone());
} }
self.status = format!("Git {}", git_request_name(&request)); self.status = format!("Git {}", git_request_name(&request));
Task::perform( Task::perform(
async move { async move {
Arc::new(Mutex::new(Some(storage.git_operation( let result = match retry {
handle.as_ref(), GitRetry::Normal => storage.git_operation(handle.as_ref(), &request, &control),
&request, GitRetry::ConfirmHost(host_key) => storage
&control, .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 { move |completion| Message::GitFinished {
generation, generation,
@@ -4641,7 +4801,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
} }
UtilityView::Git(form) => { UtilityView::Git(form) => {
content = content.push(text( 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 { if let Some(phase) = form.progress {
content = content.push(text(format!("Progress: {}", git_phase_name(phase)))); 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 { if let Some(error) = &form.error {
content = content.push(text(format!("Git error: {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 { if let Some(snapshot) = &form.snapshot {
content = content content = content
.push(text(format!("Repository: {}", snapshot.root().display()))) .push(text(format!("Repository: {}", snapshot.root().display())))
.push(text(format!("Branch: {}", snapshot.branch()))); .push(text(format!("Branch: {}", snapshot.branch())));
if let Some(remote) = snapshot.remote() { if let Some(remote) = snapshot.remote() {
content = content.push(text(format!( content = content.push(text(format!(
"HTTPS remote: {} · {} · {} ahead / {} behind", "{} remote: {} · {} · {} ahead / {} behind",
remote.transport(),
remote.name(), remote.name(),
remote.url(), remote.url(),
remote.ahead(), remote.ahead(),
remote.behind() remote.behind()
))); )));
} else { } else {
content = content.push(text("No HTTPS remote is configured.")); content = content.push(text("No Git remote is configured."));
} }
let status = snapshot.status(); let status = snapshot.status();
content = content.push(text(if status.is_clean() { 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("Git actions").size(22))
.push(text( .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("OTP actions").size(22))
.push(text( .push(text(
@@ -4992,6 +5189,16 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
UtilityView::Git(form) => { UtilityView::Git(form) => {
if form.running { if form.running {
row![utility_button("Cancel Git operation").on_press(Message::CancelGit)] 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 { } else {
let mut actions = row![ let mut actions = row![
utility_icon_control( utility_icon_control(
@@ -5878,8 +6085,8 @@ fn git_request_name(request: &DesktopGitRequest) -> &'static str {
fn git_phase_name(phase: GitProgressPhase) -> &'static str { fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase { match phase {
GitProgressPhase::Validating => "validating the repository and HTTPS remote", GitProgressPhase::Validating => "validating the repository and remote",
GitProgressPhase::Authenticating => "requesting secure HTTPS credentials", GitProgressPhase::Authenticating => "verifying remote trust and credentials",
GitProgressPhase::Receiving => "receiving remote objects", GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes", GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects", 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() "No embedded Git repository exists for this password store.".to_owned()
} }
Some(GitError::ForbiddenRemoteUrl) => { 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 }) => { 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) => { Some(GitError::CredentialsUnavailable) => {
"HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned() "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() "The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned()
} }
Some(GitError::NetworkUnavailable) => { 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) => { Some(GitError::TlsFailed) => {
"TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned() "TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned()
@@ -7534,7 +7782,7 @@ mod tests {
} }
#[test] #[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 (temporary, _initial) = fixture_storage();
let config_path = temporary.path().join("config.toml"); let config_path = temporary.path().join("config.toml");
let mut config = fs::read_to_string(&config_path).expect("configuration"); let mut config = fs::read_to_string(&config_path).expect("configuration");
@@ -7564,6 +7812,10 @@ mod tests {
result.snapshot().remote().expect("remote").url(), result.snapshot().remote().expect("remote").url(),
"https://example.test/store.git" "https://example.test/store.git"
); );
assert_eq!(
result.snapshot().remote().expect("remote").transport(),
ironstorage::config::RemoteTransport::Https
);
assert_eq!( assert_eq!(
*phases.lock().expect("progress"), *phases.lock().expect("progress"),
[GitProgressPhase::Validating] [GitProgressPhase::Validating]
@@ -7596,7 +7848,7 @@ mod tests {
&DesktopGitRequest::Refresh, &DesktopGitRequest::Refresh,
&GitOperationControl::default(), &GitOperationControl::default(),
) )
.expect_err("non-HTTPS remote rejection"); .expect_err("configured remote mismatch");
assert_eq!(error.kind(), DesktopErrorKind::Git); assert_eq!(error.kind(), DesktopErrorKind::Git);
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl)); assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
assert_eq!( assert_eq!(
@@ -7609,6 +7861,24 @@ mod tests {
assert!(!DesktopGitRequest::Push.changes_worktree()); 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] #[test]
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() { fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
let (_temporary, storage) = fixture_storage(); let (_temporary, storage) = fixture_storage();

View File

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

View File

@@ -4,13 +4,14 @@ use std::{collections::BTreeSet, time::Instant};
use ironstorage::{ use ironstorage::{
command::{ command::{
CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, CommandRequest, GitRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
Presentation, help_text, otp_version_text, version_text, Presentation, help_text, otp_version_text, version_text,
}, },
config::Config, config::Config,
config::SshFingerprint,
crypto::KeyInfo, crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId}, document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot}, git::{GitConflict, GitProgressPhase, GitSnapshot, SshHostKey},
otp::OtpCodeValidity, otp::OtpCodeValidity,
presentation::{ClipboardDisposition, ClipboardError, QrMatrix}, presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
read::{FindResults, GrepResults, TreeModel}, read::{FindResults, GrepResults, TreeModel},
@@ -223,6 +224,14 @@ pub enum AsyncPayload {
conflicts: Vec<GitConflict>, conflicts: Vec<GitConflict>,
details: Option<SecretBytes>, details: Option<SecretBytes>,
}, },
GitHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
GitPassphraseRequired {
request: GitRequest,
fingerprint: SshFingerprint,
},
OtpCodeFinished { OtpCodeFinished {
entry: String, entry: String,
field: Option<EntryFieldId>, field: Option<EntryFieldId>,
@@ -301,6 +310,15 @@ pub enum AppEffect {
}, },
AuthenticateWorkflow(Box<WorkflowSubmission>), AuthenticateWorkflow(Box<WorkflowSubmission>),
AuthenticateGit(ironstorage::command::GitRequest), AuthenticateGit(ironstorage::command::GitRequest),
RetryGitAfterHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
RetryGitWithPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
CancelGit, CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>), ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
AuthenticateOtp(OtpUiRequest), AuthenticateOtp(OtpUiRequest),
@@ -536,6 +554,14 @@ impl App {
self.status = format!("{label} queued for secure-storage authentication…"); 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> { pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease self.remaining_lease
} }
@@ -861,6 +887,26 @@ impl App {
self.focus = PaneFocus::Main; self.focus = PaneFocus::Main;
self.status = message; 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 { Ok(AsyncPayload::OtpCodeFinished {
entry, entry,
field, field,
@@ -1337,6 +1383,22 @@ impl App {
} }
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)), WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() { 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) => { Ok(submission) => {
self.workflow_pending = true; self.workflow_pending = true;
self.status = "Authenticating before applying the workflow…".to_owned(); self.status = "Authenticating before applying the workflow…".to_owned();
@@ -2126,7 +2188,7 @@ impl App {
fn git_phase_name(phase: GitProgressPhase) -> &'static str { fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase { match phase {
GitProgressPhase::Validating => "validating repository", GitProgressPhase::Validating => "validating repository",
GitProgressPhase::Authenticating => "requesting credentials", GitProgressPhase::Authenticating => "verifying remote and credentials",
GitProgressPhase::Receiving => "receiving remote objects", GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes", GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects", GitProgressPhase::Sending => "sending local objects",
@@ -2744,4 +2806,40 @@ mod tests {
assert!(rows.contains("••••••••")); assert!(rows.contains("••••••••"));
assert!(!rows.contains("NEVER-RENDER")); 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) => { AppEffect::AuthenticateOtp(request) => {
if let Some(coordinator) = authentication.as_mut() { if let Some(coordinator) = authentication.as_mut() {
coordinator.request_otp(request); coordinator.request_otp(request);
@@ -557,7 +608,9 @@ fn apply_app_effect(
let control = let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone()); *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( 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( fn execute_git(
config: &ironstorage::config::Config, config: &ironstorage::config::Config,
request: ironstorage::command::GitRequest, request: ironstorage::command::GitRequest,
mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>, mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
ssh_passphrase: Option<(
ironstorage::config::SshFingerprint,
ironstorage::repository::SecretBytes,
)>,
control: &ironstorage::git::GitOperationControl, control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> { ) -> Result<AsyncPayload, String> {
use ironstorage::{ use ironstorage::{
command::{GitConfigRequest, GitRemoteRequest, GitRequest}, command::{GitConfigRequest, GitRemoteRequest, GitRequest},
git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport}, git::{GitError, GitIdentity, GitRepository},
repository::{Repository, SecretBytes}, repository::{Repository, SecretBytes},
}; };
@@ -744,20 +854,20 @@ fn execute_git(
} }
}, },
GitRequest::Fetch { remote } => { GitRequest::Fetch { remote } => {
let retry = GitRequest::Fetch {
remote: remote.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .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 let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git fetch requires authentication".to_owned())?; .ok_or_else(|| "Git fetch requires authentication".to_owned())?;
let outcome = git let provider = remote_credentials(handle, ssh_passphrase.as_ref());
.fetch_with_transport_controlled( let outcome = match git.fetch_controlled(configured, &provider, control) {
configured, Ok(outcome) => outcome,
handle, Err(error) => return git_interaction_or_error(retry, error),
&EmbeddedFetchTransport, };
control,
)
.map_err(|error| error.to_string())?;
format!( format!(
"Fetched {}{}", "Fetched {}{}",
outcome.remote(), outcome.remote(),
@@ -769,19 +879,18 @@ fn execute_git(
) )
} }
GitRequest::Pull { remote, branch } => { GitRequest::Pull { remote, branch } => {
let retry = GitRequest::Pull {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .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 let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git pull requires authentication".to_owned())?; .ok_or_else(|| "Git pull requires authentication".to_owned())?;
match git.pull_with_transport_controlled( let provider = remote_credentials(handle, ssh_passphrase.as_ref());
configured, match git.pull_controlled(configured, branch.as_deref(), &provider, control) {
branch.as_deref(),
handle,
&EmbeddedFetchTransport,
control,
) {
Ok(outcome) => { Ok(outcome) => {
changed_snapshot = true; changed_snapshot = true;
format!("Git pull completed: {outcome:?}") format!("Git pull completed: {outcome:?}")
@@ -801,25 +910,26 @@ fn execute_git(
details: None, details: None,
}); });
} }
Err(error) => return Err(error.to_string()), Err(error) => return git_interaction_or_error(retry, error),
} }
} }
GitRequest::Push { remote, branch } => { GitRequest::Push { remote, branch } => {
let retry = GitRequest::Push {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .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 let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git push requires authentication".to_owned())?; .ok_or_else(|| "Git push requires authentication".to_owned())?;
let outcome = git let provider = remote_credentials(handle, ssh_passphrase.as_ref());
.push_with_transport_controlled( let outcome =
configured, match git.push_controlled(configured, branch.as_deref(), &provider, control) {
branch.as_deref(), Ok(outcome) => outcome,
handle, Err(error) => return git_interaction_or_error(retry, error),
&ReqwestGitTransport, };
control,
)
.map_err(|error| error.to_string())?;
format!( format!(
"Pushed {} {}", "Pushed {} {}",
outcome.remote(), outcome.remote(),
@@ -827,19 +937,17 @@ fn execute_git(
) )
} }
GitRequest::Sync { remote } => { GitRequest::Sync { remote } => {
let retry = GitRequest::Sync {
remote: remote.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .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 let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git synchronization requires authentication".to_owned())?; .ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
let pull = match git.pull_with_transport_controlled( let provider = remote_credentials(handle, ssh_passphrase.as_ref());
configured, let (pull, push) = match git.sync_controlled(configured, &provider, control) {
None,
handle,
&EmbeddedFetchTransport,
control,
) {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(GitError::MergeConflicts { conflicts }) => { Err(GitError::MergeConflicts { conflicts }) => {
let snapshot = git let snapshot = git
@@ -856,17 +964,8 @@ fn execute_git(
details: None, 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; changed_snapshot = true;
format!( format!(
"Git synchronization completed: {pull:?}; pushed {}", "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 control
.report(ironstorage::git::GitProgressPhase::Refreshing) .report(ironstorage::git::GitProgressPhase::Refreshing)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@@ -906,7 +1010,7 @@ fn execute_git_resolution(
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let configured = config let configured = config
.git_remote(None) .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()) let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage()) let git = GitRepository::open(&repository, GitIdentity::ironstorage())
@@ -1117,7 +1221,7 @@ fn apply_authentication_event(
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone()); *git_control = Some(control.clone());
executor.submit(token, move || { executor.submit(token, move || {
execute_git(&config, request, Some(handle), &control) execute_git(&config, request, Some(handle), None, &control)
}); });
} }
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => { AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
@@ -1203,6 +1307,9 @@ fn execute_workflow(
let mut presentation = None; let mut presentation = None;
let mut refresh_tree = true; let mut refresh_tree = true;
let (entry, mut status) = match submission { let (entry, mut status) = match submission {
WorkflowSubmission::SshHost { .. } | WorkflowSubmission::SshPassphrase { .. } => {
unreachable!("SSH interactions are routed before storage workflows")
}
WorkflowSubmission::Init(request) => { WorkflowSubmission::Init(request) => {
let directory = request.path.as_deref().unwrap_or_default(); let directory = request.path.as_deref().unwrap_or_default();
let mut committer = 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] #[test]
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() { fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
let mut app = App::new(); 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() { if let Some(remote) = snapshot.remote() {
lines.push(Line::raw(format!( lines.push(Line::raw(format!(
"remote: {} {}", "remote: {} {} {}",
remote.transport(),
remote.name(), remote.name(),
remote.url() remote.url()
))); )));

View File

@@ -5,11 +5,13 @@ use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{ use ironstorage::{
command::{ command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput, CopyRequest, GenerateRequest, GeneratedPresentation, GitRequest, GrepRequest, InitRequest,
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest, InsertInput, InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource,
RemoveRequest, OtpInsertRequest, RemoveRequest,
}, },
config::SshFingerprint,
crypto::KeyInfo, crypto::KeyInfo,
git::SshHostKey,
kdbx::{KdbxImportMode, KdbxImportRequest}, kdbx::{KdbxImportMode, KdbxImportRequest},
otp::OtpInput, otp::OtpInput,
repository::SecretBytes, repository::SecretBytes,
@@ -167,6 +169,20 @@ pub struct OtpForm {
focus: usize, 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)] #[derive(Debug)]
pub enum WorkflowForm { pub enum WorkflowForm {
Init(InitForm), Init(InitForm),
@@ -178,6 +194,8 @@ pub enum WorkflowForm {
Move(TransferForm), Move(TransferForm),
Copy(TransferForm), Copy(TransferForm),
Otp(OtpForm), Otp(OtpForm),
SshHost(SshHostForm),
SshPassphrase(SshPassphraseForm),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -217,6 +235,15 @@ pub enum WorkflowSubmission {
OtpValidate { OtpValidate {
uri: OtpInput, uri: OtpInput,
}, },
SshHost {
request: GitRequest,
host_key: SshHostKey,
},
SshPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -228,6 +255,22 @@ pub enum WorkflowInput {
} }
impl WorkflowForm { 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 { pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
Self::Otp(OtpForm { Self::Otp(OtpForm {
kind, kind,
@@ -409,6 +452,8 @@ impl WorkflowForm {
OtpFormKind::Append => "Append OTP URI", OtpFormKind::Append => "Append OTP URI",
OtpFormKind::Validate => "Validate 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::Kdbx(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4, Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
Self::Otp(_) => 4, Self::Otp(_) => 4,
Self::SshHost(_) | Self::SshPassphrase(_) => 1,
} }
} }
@@ -815,6 +902,7 @@ impl WorkflowForm {
Self::Remove(form) => &mut form.focus, Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus, Self::Move(form) | Self::Copy(form) => &mut form.focus,
Self::Otp(form) => &mut form.focus, Self::Otp(form) => &mut form.focus,
Self::SshHost(_) | Self::SshPassphrase(_) => return,
}; };
*focus = if forward { *focus = if forward {
(*focus + 1) % count (*focus + 1) % count
@@ -863,6 +951,7 @@ impl WorkflowForm {
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true, 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 == 2 => form.force ^= true,
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true, Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
Self::SshHost(form) => form.confirmed ^= true,
_ => self.character(' '), _ => self.character(' '),
} }
} }
@@ -919,6 +1008,7 @@ impl WorkflowForm {
Self::Otp(form) if form.focus == 1 => { Self::Otp(form) if form.focus == 1 => {
form.uri.pop(); 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 == 0 => form.entry.push(character),
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.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] #[test]
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() { fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
let mut form = WorkflowForm::insert(None); let mut form = WorkflowForm::insert(None);

View File

@@ -13,5 +13,5 @@ name = "ironstorage_apple"
crate-type = ["lib", "staticlib", "cdylib"] crate-type = ["lib", "staticlib", "cdylib"]
[dependencies] [dependencies]
ironstorage.workspace = true ironstorage = { path = "../storage", default-features = false, features = ["full"] }
uniffi.workspace = true uniffi.workspace = true

View File

@@ -406,6 +406,22 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
.store_openpgp_passphrase(&reference, value) .store_openpgp_passphrase(&reference, value)
.map_err(Into::into) .map_err(Into::into)
} }
/// Persist a passphrase that a completed SSH operation proved could
/// unlock and authenticate the configured private key.
pub fn persist_verified_ssh_passphrase(
&self,
fingerprint: &crate::config::SshFingerprint,
value: SecretBytes,
) -> Result<(), AuthenticationError> {
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
self.shared.with_active(self.generation, |_| ())?;
self.shared
.store
.persist_verified_ssh_passphrase(fingerprint, value)
.map_err(Into::into)
}
} }
impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> { impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {

View File

@@ -22,10 +22,9 @@ use crate::{
EntryFieldKind, EntryFieldKind,
}, },
git::{ git::{
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitConflict,
EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity, GitConflictResolution, GitError, GitIdentity, GitOperationControl, GitProgressPhase,
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome, GitRemoteCredentialOverride, GitRepository, GitSnapshot, PullOutcome, PushOutcome,
PushOutcome, ReqwestGitTransport,
}, },
kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter}, kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter},
mutation::{MutationOutcome, TreeMutator}, mutation::{MutationOutcome, TreeMutator},
@@ -434,6 +433,16 @@ impl DesktopStorage {
handle: Option<&NativeAuthenticationHandle>, handle: Option<&NativeAuthenticationHandle>,
request: &DesktopGitRequest, request: &DesktopGitRequest,
control: &GitOperationControl, control: &GitOperationControl,
) -> Result<DesktopGitResult, DesktopError> {
self.git_operation_with_ssh_passphrase(handle, request, control, None)
}
pub fn git_operation_with_ssh_passphrase(
&self,
handle: Option<&NativeAuthenticationHandle>,
request: &DesktopGitRequest,
control: &GitOperationControl,
ssh_passphrase: Option<(&crate::config::SshFingerprint, &SecretBytes)>,
) -> Result<DesktopGitResult, DesktopError> { ) -> Result<DesktopGitResult, DesktopError> {
control control
.report(GitProgressPhase::Validating) .report(GitProgressPhase::Validating)
@@ -445,7 +454,7 @@ impl DesktopStorage {
self.config.git_remote(None).ok_or_else(|| { self.config.git_remote(None).ok_or_else(|| {
DesktopError::new( DesktopError::new(
DesktopErrorKind::Configuration, DesktopErrorKind::Configuration,
"no HTTPS Git remote is configured", "no Git remote is configured",
) )
}) })
}; };
@@ -453,7 +462,7 @@ impl DesktopStorage {
let handle = handle.ok_or_else(|| { let handle = handle.ok_or_else(|| {
DesktopError::new( DesktopError::new(
DesktopErrorKind::Authentication, DesktopErrorKind::Authentication,
"authentication is required for HTTPS Git credentials", "authentication is required for Git credentials",
) )
})?; })?;
handle handle
@@ -464,32 +473,53 @@ impl DesktopStorage {
let (outcome, changed_tree) = match request { let (outcome, changed_tree) = match request {
DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false), DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false),
DesktopGitRequest::Pull => { DesktopGitRequest::Pull => {
let handle = authenticated()?;
let credentials = ssh_passphrase.map_or_else(
|| GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
);
let outcome = git let outcome = git
.pull_with_transport_controlled( .pull_controlled(configured()?, None, &credentials, control)
configured()?,
None,
authenticated()?,
&EmbeddedFetchTransport,
control,
)
.map_err(DesktopError::git)?; .map_err(DesktopError::git)?;
(DesktopGitOutcome::Pulled(outcome), true) (DesktopGitOutcome::Pulled(outcome), true)
} }
DesktopGitRequest::Push => { DesktopGitRequest::Push => {
let handle = authenticated()?;
let credentials = ssh_passphrase.map_or_else(
|| GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
);
let outcome = git let outcome = git
.push_with_transport_controlled( .push_controlled(configured()?, None, &credentials, control)
configured()?,
None,
authenticated()?,
&ReqwestGitTransport,
control,
)
.map_err(DesktopError::git)?; .map_err(DesktopError::git)?;
(DesktopGitOutcome::Pushed(outcome), false) (DesktopGitOutcome::Pushed(outcome), false)
} }
DesktopGitRequest::Sync => { DesktopGitRequest::Sync => {
let handle = authenticated()?;
let credentials = ssh_passphrase.map_or_else(
|| GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
);
let (pull, push) = git let (pull, push) = git
.sync_controlled(configured()?, authenticated()?, control) .sync_controlled(configured()?, &credentials, control)
.map_err(DesktopError::git)?; .map_err(DesktopError::git)?;
(DesktopGitOutcome::Synchronized { pull, push }, true) (DesktopGitOutcome::Synchronized { pull, push }, true)
} }
@@ -515,6 +545,17 @@ impl DesktopStorage {
}) })
} }
#[cfg(feature = "ssh")]
pub fn confirm_ssh_host(&self, host_key: &crate::git::SshHostKey) -> Result<(), DesktopError> {
let remote = self.config.git_remote(None).ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::Configuration,
"no Git remote is configured",
)
})?;
crate::git::confirm_ssh_host(remote, host_key).map_err(DesktopError::git)
}
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> { pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
let repository = self.repository()?; let repository = self.repository()?;
let keys = self.keys()?; let keys = self.keys()?;

View File

@@ -155,6 +155,7 @@ impl GitConflictResolution {
pub struct GitRemoteStatus { pub struct GitRemoteStatus {
name: String, name: String,
url: String, url: String,
transport: RemoteTransport,
ahead: usize, ahead: usize,
behind: usize, behind: usize,
} }
@@ -166,6 +167,9 @@ impl GitRemoteStatus {
pub fn url(&self) -> &str { pub fn url(&self) -> &str {
&self.url &self.url
} }
pub const fn transport(&self) -> RemoteTransport {
self.transport
}
pub fn ahead(&self) -> usize { pub fn ahead(&self) -> usize {
self.ahead self.ahead
} }
@@ -834,6 +838,69 @@ pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProv
impl<T: GitCredentialProvider + SshPassphraseProvider> GitRemoteCredentialProvider for T {} impl<T: GitCredentialProvider + SshPassphraseProvider> GitRemoteCredentialProvider for T {}
/// Adds one explicitly supplied SSH key passphrase to an existing credential
/// provider without changing HTTPS credential lookup or key selection.
pub struct GitRemoteCredentialOverride<'a, P> {
provider: &'a P,
ssh_passphrase: Option<(&'a SshFingerprint, &'a SecretBytes)>,
}
impl<'a, P> GitRemoteCredentialOverride<'a, P> {
pub const fn new(provider: &'a P) -> Self {
Self {
provider,
ssh_passphrase: None,
}
}
pub const fn with_ssh_passphrase(
provider: &'a P,
fingerprint: &'a SshFingerprint,
passphrase: &'a SecretBytes,
) -> Self {
Self {
provider,
ssh_passphrase: Some((fingerprint, passphrase)),
}
}
}
impl<P: GitCredentialProvider> GitCredentialProvider for GitRemoteCredentialOverride<'_, P> {
fn credential(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<GitCredential, GitError> {
self.provider.credential(server, application)
}
}
impl<P: SshPassphraseProvider> SshPassphraseProvider for GitRemoteCredentialOverride<'_, P> {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
if let Some((supplied_fingerprint, passphrase)) = self.ssh_passphrase
&& supplied_fingerprint == fingerprint
{
return Ok(SecretBytes::new(passphrase.expose().to_vec()));
}
self.provider.ssh_key_passphrase(fingerprint)
}
}
#[cfg(feature = "ssh")]
pub fn confirm_ssh_host(remote: &GitRemote, host_key: &SshHostKey) -> Result<(), GitError> {
let endpoint = remote
.endpoint()
.as_ssh()
.ok_or(GitError::SshAuthenticationNotConfigured)?;
if endpoint.host() != host_key.host() || endpoint.port() != host_key.port() {
return Err(GitError::SshProtocolFailed);
}
let authentication = remote
.ssh_authentication()
.ok_or(GitError::SshAuthenticationNotConfigured)?;
crate::ssh::persist_confirmed_host(authentication.known_hosts_file(), host_key)
}
pub trait GitSmartHttpTransport { pub trait GitSmartHttpTransport {
fn advertise_receive_pack( fn advertise_receive_pack(
&self, &self,
@@ -1718,6 +1785,20 @@ impl GitRepository {
self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport) self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
} }
pub fn fetch_controlled(
&self,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<FetchOutcome, GitError> {
self.fetch_with_transport_controlled(
configured,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn fetch_with_transport( pub fn fetch_with_transport(
&self, &self,
configured: &GitRemote, configured: &GitRemote,
@@ -1921,6 +2002,22 @@ impl GitRepository {
self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport) self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
} }
pub fn pull_controlled(
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<PullOutcome, GitError> {
self.pull_with_transport_controlled(
configured,
branch,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn pull_with_transport( pub fn pull_with_transport(
&self, &self,
configured: &GitRemote, configured: &GitRemote,
@@ -2065,7 +2162,7 @@ impl GitRepository {
) )
} }
fn push_controlled( pub fn push_controlled(
&self, &self,
configured: &GitRemote, configured: &GitRemote,
branch: Option<&str>, branch: Option<&str>,
@@ -2349,6 +2446,7 @@ impl GitRepository {
Ok(GitRemoteStatus { Ok(GitRemoteStatus {
name: name.to_owned(), name: name.to_owned(),
url: actual_url, url: actual_url,
transport: configured.endpoint().transport(),
ahead, ahead,
behind, behind,
}) })
@@ -2404,6 +2502,7 @@ impl GitRepository {
remote: GitRemoteStatus { remote: GitRemoteStatus {
name: name.to_owned(), name: name.to_owned(),
url: actual_url, url: actual_url,
transport: configured.endpoint().transport(),
ahead, ahead,
behind, behind,
}, },
@@ -4226,8 +4325,24 @@ mod ssh_push_tests;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{GitIdentity, GitRepository}; use super::{
use crate::repository::Repository; GitError, GitIdentity, GitRemoteCredentialOverride, GitRepository, SshPassphraseProvider,
};
use crate::{
config::SshFingerprint,
repository::{Repository, SecretBytes},
};
struct Passphrases;
impl SshPassphraseProvider for Passphrases {
fn ssh_key_passphrase(
&self,
_fingerprint: &SshFingerprint,
) -> Result<SecretBytes, GitError> {
Ok(SecretBytes::new(b"stored".to_vec()))
}
}
#[test] #[test]
fn embedded_identity_survives_local_config_updates() { fn embedded_identity_survives_local_config_updates() {
@@ -4240,4 +4355,64 @@ mod tests {
.expect("add remote"); .expect("add remote");
assert!(repository.repository.committer().is_some()); assert!(repository.repository.committer().is_some());
} }
#[test]
fn one_ssh_passphrase_override_is_bound_to_its_fingerprint() {
let selected = SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
.expect("fingerprint");
let other = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.expect("fingerprint");
let supplied = SecretBytes::new(b"prompted".to_vec());
let credentials =
GitRemoteCredentialOverride::with_ssh_passphrase(&Passphrases, &selected, &supplied);
assert_eq!(
credentials
.ssh_key_passphrase(&selected)
.expect("override")
.expose(),
b"prompted"
);
assert_eq!(
credentials
.ssh_key_passphrase(&other)
.expect("stored fallback")
.expose(),
b"stored"
);
}
#[cfg(feature = "ssh")]
#[test]
fn host_confirmation_is_bound_to_the_configured_endpoint() {
let temporary = tempfile::tempdir().expect("temporary directory");
let known_hosts = temporary.path().join("known_hosts");
let fingerprint =
SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
.expect("fingerprint");
let authentication = crate::config::SshRemoteAuthentication::agent(
fingerprint.clone(),
None,
known_hosts.clone(),
)
.expect("authentication");
let remote = crate::config::GitRemote::ssh_with_authentication(
"origin",
"ssh://git@example.test:2222/team/store.git",
authentication,
)
.expect("remote");
let observed = super::SshHostKey::new(
"other.example.test".to_owned(),
2222,
"ssh-ed25519".to_owned(),
fingerprint,
"invalid-key".to_owned(),
);
assert_eq!(
super::confirm_ssh_host(&remote, &observed),
Err(GitError::SshProtocolFailed)
);
assert!(!known_hosts.exists());
}
} }

View File

@@ -512,6 +512,22 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(()) Ok(())
} }
/// Store an SSH key passphrase only after a caller has proved it by
/// completing SSH public-key authentication successfully.
pub fn persist_verified_ssh_passphrase(
&self,
fingerprint: &SshFingerprint,
value: SecretBytes,
) -> Result<(), SecretStoreError> {
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
let candidate = SecretBytes::new(value.expose().to_vec());
match self.create(&reference, candidate) {
Ok(()) => Ok(()),
Err(SecretStoreError::AlreadyExists) => self.replace(&reference, value),
Err(error) => Err(error),
}
}
/// Persist a verified OpenPGP passphrase without first reading the old item. /// Persist a verified OpenPGP passphrase without first reading the old item.
/// This lets Apple replace an item invalidated by biometric enrollment changes. /// This lets Apple replace an item invalidated by biometric enrollment changes.
pub(crate) fn store_openpgp_passphrase( pub(crate) fn store_openpgp_passphrase(

View File

@@ -289,6 +289,14 @@ fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() ->
store.ssh_key_passphrase(&fingerprint)?.expose(), store.ssh_key_passphrase(&fingerprint)?.expose(),
b"protected-passphrase" b"protected-passphrase"
); );
store.persist_verified_ssh_passphrase(
&fingerprint,
SecretBytes::new(b"verified-replacement".to_vec()),
)?;
assert_eq!(
store.ssh_key_passphrase(&fingerprint)?.expose(),
b"verified-replacement"
);
backend.fail_next(SecretStoreError::Denied); backend.fail_next(SecretStoreError::Denied);
assert!(matches!( assert!(matches!(
store.ssh_key_passphrase(&fingerprint), store.ssh_key_passphrase(&fingerprint),

View File

@@ -68,10 +68,13 @@ test requires an upstream executable at runtime.
The executable audit checks project Rust sources for process construction and The executable audit checks project Rust sources for process construction and
permits it only in the editor adapter. Every project crate forbids unsafe Rust. permits it only in the editor adapter. Every project crate forbids unsafe Rust.
Git repository configuration rejects executable helpers and all non-HTTPS, Git repository configuration rejects executable helpers, unsupported schemes,
credential-bearing or rewritten remote forms before transport. Error and debug credential-bearing URLs, and rewritten remote forms before transport. The
models redact secret bytes; CLI presentation tests assert clipboard, QR, OTP normal CLI build accepts typed HTTPS, `ssh://`, and scp-like remotes. Unknown SSH
and generated values do not appear on unintended streams. hosts require an explicit fingerprint confirmation on standard error; encrypted
keys use hidden terminal input and no command-line passphrase option. Error and
debug models redact secret bytes; CLI presentation tests assert clipboard, QR,
OTP, generated values, and SSH passphrases do not appear on unintended streams.
The activated dependency graph was reviewed with `cargo tree -e features` and The activated dependency graph was reviewed with `cargo tree -e features` and
`cargo metadata --locked`. Gix default features are disabled and only the `cargo metadata --locked`. Gix default features are disabled and only the

View File

@@ -95,7 +95,8 @@ Private-key passphrases are stored by fingerprint in the operating-system
secret store and never appear in TOML. IronStorage does not read OpenSSH secret store and never appear in TOML. IronStorage does not read OpenSSH
configuration, try additional keys, prompt for passwords or configuration, try additional keys, prompt for passwords or
keyboard-interactive authentication, launch an agent, or invoke proxy/helper keyboard-interactive authentication, launch an agent, or invoke proxy/helper
commands. In builds with the `ssh` feature, the same configuration drives commands. The normal CLI, TUI, and desktop builds enable the storage `ssh`
feature, so the same configuration drives
branch discovery, clone, fetch, and pull over the embedded upload-pack channel; branch discovery, clone, fetch, and pull over the embedded upload-pack channel;
push and full pull-then-push synchronization use the matching embedded push and full pull-then-push synchronization use the matching embedded
receive-pack channel. receive-pack channel.
@@ -103,7 +104,10 @@ receive-pack channel.
The typed endpoint model is always available so an SSH remote remains readable The typed endpoint model is always available so an SSH remote remains readable
through the Rust API even when the binary was built without SSH. Such a build through the Rust API even when the binary was built without SSH. Such a build
returns a typed unsupported-transport error before connection or repository returns a typed unsupported-transport error before connection or repository
mutation. The optional storage `ssh` feature contains `russh` 0.63.1 and Tokio; mutation. The Apple bridge explicitly enables the storage `full` feature set
without `ssh`, keeping the iPhone, AutoFill, and Watch dependency graph
HTTPS-only until an Apple target opts in. The optional storage `ssh` feature
contains `russh` 0.63.1 and Tokio;
`russh` default features are disabled and the Ring backend plus RSA key support `russh` default features are disabled and the Ring backend plus RSA key support
are selected explicitly. are selected explicitly.

View File

@@ -111,7 +111,7 @@ remain native-host smoke checks because CI cannot emulate those OS services.
| Authentication expiry | Storage authentication leases own the clock and policy. Passive ticks, rendering, pointer movement, and window events do not renew activity; deterministic tests cover expiry during protected state. | | Authentication expiry | Storage authentication leases own the clock and policy. Passive ticks, rendering, pointer movement, and window events do not renew activity; deterministic tests cover expiry during protected state. |
| Dirty documents and conflicts | Every entry/vault/window/Git worktree replacement routes through one Save/Discard/Cancel decision. Failed saves and conflicts keep the complete draft. | | Dirty documents and conflicts | Every entry/vault/window/Git worktree replacement routes through one Save/Discard/Cancel decision. Failed saves and conflicts keep the complete draft. |
| Background and window lifecycle | Generation counters reject stale asynchronous results. Lock cancels Git/clipboard work and clears OTP, QR, URI, entry, and editor state. Close and quit use the same dirty guard. | | Background and window lifecycle | Generation counters reject stale asynchronous results. Lock cancels Git/clipboard work and clears OTP, QR, URI, entry, and editor state. Close and quit use the same dirty guard. |
| Repository and domain ownership | The executable source audit rejects repository/Git construction, process launch, OTP/QR parsing, filesystem writes, unsafe blocks, and non-HTTPS literals in production desktop modules. The folder picker may read only a user-selected QR image; all password-store I/O remains in `crates/storage`. | | Repository and domain ownership | The executable source audit rejects repository/Git construction, process launch, OTP/QR parsing, filesystem writes, unsafe blocks, and insecure HTTP literals in production desktop modules. HTTPS and SSH endpoint parsing, host trust, authentication, and protocol behavior remain in `crates/storage`; desktop only presents typed state and native confirmation or masked-passphrase prompts. The folder picker may read only a user-selected QR image; all password-store I/O remains in `crates/storage`. |
Run the complete repository gate after the desktop-specific checks: Run the complete repository gate after the desktop-specific checks:

View File

@@ -60,6 +60,15 @@ commands. SHA-1 host signatures and `ssh-rsa` authentication are excluded.
Cancellation interrupts connection and authentication without changing Git, Cancellation interrupts connection and authentication without changing Git,
known hosts, or secure storage. known hosts, or secure storage.
The standard CLI, TUI, and desktop applications compile this transport and pass
their Git actions through storage's typed endpoint selection. They display both
SSH URL forms without reparsing them. An unknown key opens an explicit native
confirmation showing the host, port, algorithm, and fingerprint; a changed key
is a non-bypassable error. Encrypted-key prompts use hidden or masked input and
retain a supplied passphrase only after SSH authentication and the requested Git
operation succeed. Cancelling either prompt leaves the repository, known hosts,
and secure storage unchanged.
## SSH upload-pack ## SSH upload-pack
With the same feature enabled, branch discovery, clone, fetch, and pull open a With the same feature enabled, branch discovery, clone, fetch, and pull open a
@@ -110,3 +119,6 @@ counts, pack checksums, non-fast-forward behavior, and server status. SSH tests
use a pure-Rust in-process Russh server and upload-pack fixture to exercise use a pure-Rust in-process Russh server and upload-pack fixture to exercise
chunked reference and pack streams, end-to-end clone/fetch/pull, command chunked reference and pack streams, end-to-end clone/fetch/pull, command
quoting, cancellation, and rollback without an external Git or SSH executable. quoting, cancellation, and rollback without an external Git or SSH executable.
Frontend tests cover typed transport display, prompt masking and cancellation,
retry routing, conflicts, authentication failures, and successful HTTPS
regression paths without duplicating protocol logic.

View File

@@ -49,7 +49,11 @@ caller explicitly selects `SecretCachePolicy::Timed`. Timed policies are capped
at 128 entries and 15 minutes, expire lazily, and are always cleared on lock. at 128 entries and 15 minutes, expire lazily, and are always cleared on lock.
The same unlocked store implements the OpenPGP `SecretProvider`, HTTPS Git The same unlocked store implements the OpenPGP `SecretProvider`, HTTPS Git
`GitCredentialProvider`, and SSH `SshPassphraseProvider`. The CLI uses it for `GitCredentialProvider`, and SSH `SshPassphraseProvider`. CLI, TUI, and desktop
terminal `show` and embedded Git, proving that protected keys and remote pass one prompted SSH passphrase as zeroizing bytes for one retry. Storage binds
authentication are resolved only through opaque references. Tests inject a that override to the requested key fingerprint, and the authentication handle
memory backend and never access a developer or CI user keyring. persists it only after the Git operation succeeds; cancellation, rejection, and
other failures never create or replace a record. Prompts are hidden or masked,
and secret values are excluded from arguments, history, normal output, debug
models, notifications, and the clipboard. Tests inject a memory backend and
never access a developer or CI user keyring.