diff --git a/README.md b/README.md index c2593d4..d2c36c1 100644 --- a/README.md +++ b/README.md @@ -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 feature-gated SSH. HTTPS synchronization uses server/application credentials 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, 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. The bundle contains the matching `ironstorage` CLI and `ironstorage-tui` binaries; 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: diff --git a/apple/TESTING.md b/apple/TESTING.md index a6e16c8..69d4c4e 100644 --- a/apple/TESTING.md +++ b/apple/TESTING.md @@ -22,7 +22,10 @@ actually exercised. 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 -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 @@ -31,7 +34,8 @@ Git, non-TOTP Watch features, and production AutoFill behavior. - The Apple Rust crates are mechanical UniFFI projections. Swift may collect input and call those APIs; it does not open a password-store repository, 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 Keychain item. - Apple production sources contain no logging calls. Secret values use typed diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index ef54aa8..d35c5e0 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [dependencies] ctrlc.workspace = true -ironstorage.workspace = true +ironstorage = { workspace = true, features = ["ssh"] } rpassword.workspace = true tempfile = "3" diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index f28ce61..0ff577e 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -22,10 +22,13 @@ use ironstorage::{ InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text, otp_version_text, parse_from, version_text, }, - config::Config, + config::{Config, GitRemote, SshFingerprint}, crypto::KeyStore, generate::{GeneratorConfig, PasswordGenerator}, - git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome}, + git::{ + GitChangeKind, GitError, GitIdentity, GitRemoteCredentialOverride, + GitRemoteCredentialProvider, GitRepository, PullOutcome, + }, kdbx::KdbxImporter, mutation::{ MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, @@ -718,7 +721,7 @@ fn execute_secure_with_services< Some(configured) => configured, None => { 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(|_| ())?; return Ok(EXIT_CONFIG); } @@ -727,30 +730,86 @@ fn execute_secure_with_services< Ok(git) => git, Err(error) => return operation_error(stderr, error), }; - let result = match request { - GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None), - GitRequest::Pull { branch, .. } => git - .pull(configured, branch.as_deref(), secrets) - .map(|outcome| Some(pull_outcome_name(outcome).to_owned())), - GitRequest::Push { branch, .. } => git - .push(configured, branch.as_deref(), secrets) - .map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))), - GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| { - Some(format!( - "{}; {} {}", - pull_outcome_name(pull), - push.remote(), - push.new_id() - )) - }), - _ => unreachable!("local Git requests do not require secret access"), + let mut supplied_passphrase = None; + let mut confirmed_host = false; + let result = loop { + let credentials = supplied_passphrase.as_ref().map_or_else( + || GitRemoteCredentialOverride::new(&*secrets), + |(fingerprint, passphrase)| { + GitRemoteCredentialOverride::with_ssh_passphrase( + &*secrets, + fingerprint, + passphrase, + ) + }, + ); + match run_remote_git(&git, configured, request, &credentials) { + Err(GitError::UnknownSshHostKey { host_key }) if !confirmed_host => { + let decision = interaction.confirm( + &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 { 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(|_| ())?; 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), } } @@ -1068,6 +1127,11 @@ trait CliInteraction: OtpInteraction { plaintext: &SecretBytes, config: &Config, ) -> Result<(SecretBytes, String), CliInteractionError>; + + fn read_ssh_passphrase( + &mut self, + fingerprint: &SshFingerprint, + ) -> Result; } struct NativeOtpInteraction; @@ -1217,6 +1281,18 @@ impl CliInteraction for NativeOtpInteraction { .map_err(CliInteractionError::Editor)?; Ok((replacement, name)) } + + fn read_ssh_passphrase( + &mut self, + fingerprint: &SshFingerprint, + ) -> Result { + 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)] @@ -1269,6 +1345,13 @@ impl CliInteraction for UnavailableOtpInteraction { ) -> Result<(SecretBytes, String), CliInteractionError> { Err(CliInteractionError::Input) } + + fn read_ssh_passphrase( + &mut self, + _fingerprint: &SshFingerprint, + ) -> Result { + Err(CliInteractionError::Input) + } } #[derive(Debug)] @@ -1548,6 +1631,32 @@ fn select_remote<'a>( } } +fn run_remote_git( + git: &GitRepository, + configured: &GitRemote, + request: &GitRequest, + credentials: &impl GitRemoteCredentialProvider, +) -> Result, 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(stderr: &mut E, error: impl std::fmt::Display) -> Result { writeln!(stderr, "{error}").map_err(|_| ())?; Ok(EXIT_FAILURE) @@ -1652,6 +1761,7 @@ mod tests { insert_inputs: VecDeque, edit_replacements: VecDeque, kdbx_passwords: VecDeque, + ssh_passphrases: VecDeque, decisions: VecDeque, plans: Vec, prompts: Vec, @@ -1715,6 +1825,15 @@ mod tests { .map(|replacement| (replacement, "fixture-editor".to_owned())) .ok_or(super::CliInteractionError::Input) } + + fn read_ssh_passphrase( + &mut self, + _fingerprint: &super::SshFingerprint, + ) -> Result { + self.ssh_passphrases + .pop_front() + .ok_or(super::CliInteractionError::Input) + } } 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 { fn create( &self, diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 2087d46..0b35c27 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -29,7 +29,7 @@ path = "src/main.rs" [dependencies] iced.workspace = true -ironstorage.workspace = true +ironstorage = { workspace = true, features = ["ssh"] } zeroize.workspace = true [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index cb11389..7238a7e 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -37,6 +37,7 @@ use ironstorage::{ NativeAuthenticationHandle, NativeAuthenticationSession, }, command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest}, + config::SshFingerprint, crypto::KeyInfo, desktop::{ DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult, @@ -50,7 +51,7 @@ use ironstorage::{ generate::GeneratorConfig, git::{ GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl, - GitProgressPhase, GitSnapshot, + GitProgressPhase, GitSnapshot, SshHostKey, }, kdbx::{KdbxImportMode, KdbxImportRequest}, mutation::{MutationAction, MutationOutcome, MutationSelection}, @@ -160,6 +161,9 @@ enum Message { result: Box>, }, RunGit(DesktopGitRequest), + GitPassphraseChanged(Zeroizing), + SubmitGitInteraction, + CancelGitInteraction, ChooseGitConflict(usize, GitConflictChoice), ResolveGitConflicts, CancelGit, @@ -521,13 +525,55 @@ struct GitConflictSelection { choice: Option, } -#[derive(Clone, Debug, Default)] +enum GitInteraction { + ConfirmHost { + request: DesktopGitRequest, + host_key: SshHostKey, + }, + Passphrase { + request: DesktopGitRequest, + fingerprint: SshFingerprint, + value: Zeroizing, + }, +} + +impl std::fmt::Debug for GitInteraction { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConfirmHost { request, host_key } => formatter + .debug_struct("ConfirmHost") + .field("request", request) + .field("host_key", host_key) + .finish(), + Self::Passphrase { + request, + fingerprint, + .. + } => formatter + .debug_struct("Passphrase") + .field("request", request) + .field("fingerprint", fingerprint) + .field("value", &"[REDACTED]") + .finish(), + } + } +} + +#[derive(Debug, Default)] struct GitForm { snapshot: Option, progress: Option, running: bool, error: Option, conflicts: Vec, + active_request: Option, + interaction: Option, +} + +enum GitRetry { + Normal, + ConfirmHost(SshHostKey), + Passphrase(SshFingerprint, SecretBytes), } impl GitForm { @@ -1674,6 +1720,56 @@ impl App { self.execute_action(pending) }; } + Message::GitPassphraseChanged(value) => { + if let Some(UtilityView::Git(GitForm { + interaction: Some(GitInteraction::Passphrase { value: current, .. }), + running: false, + .. + })) = &mut self.utility + { + *current = value; + } + } + Message::SubmitGitInteraction => { + let interaction = match &mut self.utility { + Some(UtilityView::Git(form)) if !form.running => form.interaction.take(), + _ => None, + }; + match interaction { + Some(GitInteraction::ConfirmHost { request, host_key }) => { + return self.begin_git_retry(request, GitRetry::ConfirmHost(host_key)); + } + Some(GitInteraction::Passphrase { + request, + fingerprint, + value, + }) if !value.is_empty() => { + return self.begin_git_retry( + request, + GitRetry::Passphrase( + fingerprint, + SecretBytes::new(value.as_bytes().to_vec()), + ), + ); + } + Some(interaction) => { + if let Some(UtilityView::Git(form)) = &mut self.utility { + form.interaction = Some(interaction); + form.error = Some("Enter the SSH key passphrase.".to_owned()); + } + } + None => {} + } + } + Message::CancelGitInteraction => { + if let Some(UtilityView::Git(form)) = &mut self.utility + && !form.running + && form.interaction.take().is_some() + { + form.error = None; + self.status = "SSH authentication cancelled; repository unchanged.".to_owned(); + } + } Message::ChooseGitConflict(index, choice) => { if let Some(UtilityView::Git(form)) = &mut self.utility && !form.running @@ -1739,10 +1835,34 @@ impl App { form.running = false; form.error = None; form.conflicts.clear(); + form.active_request = None; + form.interaction = None; } self.status = git_outcome_message(&outcome); } Err(error) => { + let interaction = if let Some(UtilityView::Git(form)) = &mut self.utility { + let request = form.active_request.take(); + match (request, error.git_error()) { + (Some(request), Some(GitError::UnknownSshHostKey { host_key })) => { + Some(GitInteraction::ConfirmHost { + request, + host_key: (**host_key).clone(), + }) + } + ( + Some(request), + Some(GitError::SshKeyPassphraseUnavailable { fingerprint }), + ) => Some(GitInteraction::Passphrase { + request, + fingerprint: fingerprint.clone(), + value: Zeroizing::new(String::new()), + }), + _ => None, + } + } else { + None + }; let conflicts = error .conflicts() .iter() @@ -1756,10 +1876,27 @@ impl App { if let Some(UtilityView::Git(form)) = &mut self.utility { form.progress = None; form.running = false; - form.error = Some(message.clone()); + form.error = interaction.is_none().then(|| message.clone()); form.conflicts = conflicts; + form.interaction = interaction; } - self.status = if error.kind() == DesktopErrorKind::Conflict { + self.status = if matches!( + &self.utility, + Some(UtilityView::Git(GitForm { + interaction: Some(GitInteraction::ConfirmHost { .. }), + .. + })) + ) { + "Confirm the unknown SSH host fingerprint before retrying.".to_owned() + } else if matches!( + &self.utility, + Some(UtilityView::Git(GitForm { + interaction: Some(GitInteraction::Passphrase { .. }), + .. + })) + ) { + "Enter the encrypted SSH key passphrase to retry.".to_owned() + } else if error.kind() == DesktopErrorKind::Conflict { format!("Git requires explicit conflict resolution: {message}") } else { format!("Git operation failed: {message}") @@ -3004,6 +3141,10 @@ impl App { } fn begin_git(&mut self, request: DesktopGitRequest) -> Task { + self.begin_git_retry(request, GitRetry::Normal) + } + + fn begin_git_retry(&mut self, request: DesktopGitRequest, retry: GitRetry) -> Task { let Some(storage) = self.storage.clone() else { return Task::none(); }; @@ -3027,15 +3168,34 @@ impl App { form.progress = Some(GitProgressPhase::Validating); form.running = true; form.error = None; + form.interaction = None; + form.active_request = Some(request.clone()); } self.status = format!("Git {}…", git_request_name(&request)); Task::perform( async move { - Arc::new(Mutex::new(Some(storage.git_operation( - handle.as_ref(), - &request, - &control, - )))) + let result = match retry { + GitRetry::Normal => storage.git_operation(handle.as_ref(), &request, &control), + GitRetry::ConfirmHost(host_key) => storage + .confirm_ssh_host(&host_key) + .and_then(|()| storage.git_operation(handle.as_ref(), &request, &control)), + GitRetry::Passphrase(fingerprint, passphrase) => { + let result = storage.git_operation_with_ssh_passphrase( + handle.as_ref(), + &request, + &control, + Some((&fingerprint, &passphrase)), + ); + if result.is_ok() + && let Some(handle) = handle.as_ref() + { + let _ = + handle.persist_verified_ssh_passphrase(&fingerprint, passphrase); + } + result + } + }; + Arc::new(Mutex::new(Some(result))) }, move |completion| Message::GitFinished { generation, @@ -4641,7 +4801,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } UtilityView::Git(form) => { content = content.push(text( - "All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.", + "All repository, transport, credential, host-trust, merge, and conflict decisions are owned by crates/storage. No git, SSH, or credential-helper process is launched.", )); if let Some(phase) = form.progress { content = content.push(text(format!("Progress: {}", git_phase_name(phase)))); @@ -4649,20 +4809,57 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa if let Some(error) = &form.error { content = content.push(text(format!("Git error: {error}"))); } + match &form.interaction { + Some(GitInteraction::ConfirmHost { host_key, .. }) => { + content = content + .push(text("Unknown SSH host key").size(20)) + .push(text(format!( + "Host: {}:{}", + host_key.host(), + host_key.port() + ))) + .push(text(format!("Algorithm: {}", host_key.algorithm()))) + .push(text(format!("Fingerprint: {}", host_key.fingerprint()))) + .push(text( + "Verify this fingerprint through a trusted channel. Confirmation appends only this observed key; changed keys cannot be confirmed here.", + )); + } + Some(GitInteraction::Passphrase { + fingerprint, value, .. + }) => { + content = content + .push(text("Encrypted SSH private key").size(20)) + .push(text(format!("Key fingerprint: {fingerprint}"))) + .push( + text_input("SSH key passphrase", value) + .secure(true) + .on_input(|value| { + Message::GitPassphraseChanged(Zeroizing::new(value)) + }) + .on_submit(Message::SubmitGitInteraction) + .style(entry_input_style), + ) + .push(text( + "The passphrase is supplied to crates/storage for this retry and is never shown, logged, copied, or added to command history.", + )); + } + None => {} + } if let Some(snapshot) = &form.snapshot { content = content .push(text(format!("Repository: {}", snapshot.root().display()))) .push(text(format!("Branch: {}", snapshot.branch()))); if let Some(remote) = snapshot.remote() { content = content.push(text(format!( - "HTTPS remote: {} · {} · {} ahead / {} behind", + "{} remote: {} · {} · {} ahead / {} behind", + remote.transport(), remote.name(), remote.url(), remote.ahead(), remote.behind() ))); } else { - content = content.push(text("No HTTPS remote is configured.")); + content = content.push(text("No Git remote is configured.")); } let status = snapshot.status(); content = content.push(text(if status.is_clean() { @@ -4915,7 +5112,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa )) .push(text("Git actions").size(22)) .push(text( - "Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation. Remotes are HTTPS-only; conflicts require an explicit choice and are never silently discarded.", + "Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation for configured HTTPS and SSH remotes. Unknown SSH hosts show their fingerprint for explicit confirmation; changed keys cannot be bypassed. Encrypted key passphrases use masked input. Conflicts require an explicit choice and are never silently discarded.", )) .push(text("OTP actions").size(22)) .push(text( @@ -4992,6 +5189,16 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa UtilityView::Git(form) => { if form.running { row![utility_button("Cancel Git operation").on_press(Message::CancelGit)] + } else if let Some(interaction) = &form.interaction { + row![ + utility_button(match interaction { + GitInteraction::ConfirmHost { .. } => "Trust host and retry", + GitInteraction::Passphrase { .. } => "Unlock key and retry", + }) + .on_press(Message::SubmitGitInteraction), + utility_button("Cancel").on_press(Message::CancelGitInteraction), + ] + .spacing(8) } else { let mut actions = row![ utility_icon_control( @@ -5878,8 +6085,8 @@ fn git_request_name(request: &DesktopGitRequest) -> &'static str { fn git_phase_name(phase: GitProgressPhase) -> &'static str { match phase { - GitProgressPhase::Validating => "validating the repository and HTTPS remote", - GitProgressPhase::Authenticating => "requesting secure HTTPS credentials", + GitProgressPhase::Validating => "validating the repository and remote", + GitProgressPhase::Authenticating => "verifying remote trust and credentials", GitProgressPhase::Receiving => "receiving remote objects", GitProgressPhase::Integrating => "integrating fetched changes", GitProgressPhase::Sending => "sending local objects", @@ -5927,11 +6134,52 @@ fn git_failure_message(error: &DesktopError) -> String { "No embedded Git repository exists for this password store.".to_owned() } Some(GitError::ForbiddenRemoteUrl) => { - "The repository remote must match the configured credential-free HTTPS URL. Fix the shared configuration or repository remote, then refresh.".to_owned() + "The repository remote must match the configured credential-free Git URL. Fix the shared configuration or repository remote, then refresh.".to_owned() } Some(GitError::RemoteNotFound { name }) => { - format!("The configured HTTPS remote {name} is missing from the repository.") + format!("The configured Git remote {name} is missing from the repository.") } + Some(GitError::UnsupportedRemoteTransport { transport }) => { + format!("This build does not include the {transport} Git transport.") + } + Some(GitError::SshAuthenticationNotConfigured) => { + "The SSH remote needs one configured identity file or exact agent fingerprint." + .to_owned() + } + Some(GitError::SshIdentityMissing { path }) => { + format!("The configured SSH identity file is missing: {}.", path.display()) + } + Some(GitError::SshIdentityInvalid { path }) => { + format!("The configured SSH identity file is invalid: {}.", path.display()) + } + Some(GitError::SshKeyPassphraseDenied { .. }) => { + "Access to the stored SSH key passphrase was denied.".to_owned() + } + Some(GitError::SshKeyPassphraseCancelled { .. }) => { + "The SSH key passphrase request was cancelled.".to_owned() + } + Some(GitError::SshKeyPassphraseRejected { .. }) => { + "The supplied SSH key passphrase did not unlock the configured key.".to_owned() + } + Some(GitError::SshAgentUnavailable) => { + "The configured SSH agent socket is unavailable.".to_owned() + } + Some(GitError::SshAgentIdentityMissing { fingerprint }) => { + format!("The SSH agent does not contain the configured identity {fingerprint}.") + } + Some(GitError::SshAuthenticationRejected) => { + "The SSH server rejected the configured public-key identity.".to_owned() + } + Some(GitError::ChangedSshHostKey { host_key, line }) => format!( + "SSH host key changed for {}:{} ({} at known-hosts line {line}). This cannot be bypassed; verify and repair known hosts outside this prompt.", + host_key.host(), + host_key.port(), + host_key.fingerprint() + ), + Some(GitError::SshKnownHostsUnavailable { path }) => format!( + "The SSH known-hosts file is unavailable: {}.", + path.display() + ), Some(GitError::CredentialsUnavailable) => { "HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned() } @@ -5945,7 +6193,7 @@ fn git_failure_message(error: &DesktopError) -> String { "The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned() } Some(GitError::NetworkUnavailable) => { - "The HTTPS Git server is unreachable; check the network and retry.".to_owned() + "The Git server is unreachable; check the network and retry.".to_owned() } Some(GitError::TlsFailed) => { "TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned() @@ -7534,7 +7782,7 @@ mod tests { } #[test] - fn desktop_git_status_is_typed_https_only_and_cancellation_safe() { + fn desktop_git_status_is_transport_typed_and_cancellation_safe() { let (temporary, _initial) = fixture_storage(); let config_path = temporary.path().join("config.toml"); let mut config = fs::read_to_string(&config_path).expect("configuration"); @@ -7564,6 +7812,10 @@ mod tests { result.snapshot().remote().expect("remote").url(), "https://example.test/store.git" ); + assert_eq!( + result.snapshot().remote().expect("remote").transport(), + ironstorage::config::RemoteTransport::Https + ); assert_eq!( *phases.lock().expect("progress"), [GitProgressPhase::Validating] @@ -7596,7 +7848,7 @@ mod tests { &DesktopGitRequest::Refresh, &GitOperationControl::default(), ) - .expect_err("non-HTTPS remote rejection"); + .expect_err("configured remote mismatch"); assert_eq!(error.kind(), DesktopErrorKind::Git); assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl)); assert_eq!( @@ -7609,6 +7861,24 @@ mod tests { assert!(!DesktopGitRequest::Push.changes_worktree()); } + #[test] + fn desktop_ssh_passphrase_state_never_renders_or_debugs_secret_text() { + let fingerprint = + SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + .expect("fingerprint"); + let form = GitForm { + interaction: Some(GitInteraction::Passphrase { + request: DesktopGitRequest::Sync, + fingerprint: fingerprint.clone(), + value: Zeroizing::new("desktop secret".to_owned()), + }), + ..GitForm::default() + }; + let debug = format!("{form:?}"); + assert!(debug.contains(fingerprint.as_str())); + assert!(!debug.contains("desktop secret")); + } + #[test] fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() { let (_temporary, storage) = fixture_storage(); diff --git a/apps/tui/Cargo.toml b/apps/tui/Cargo.toml index c52c964..3e37f75 100644 --- a/apps/tui/Cargo.toml +++ b/apps/tui/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [dependencies] crossterm.workspace = true -ironstorage.workspace = true +ironstorage = { workspace = true, features = ["ssh"] } ratatui.workspace = true zeroize.workspace = true diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 8de1e7a..728549c 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -4,13 +4,14 @@ use std::{collections::BTreeSet, time::Instant}; use ironstorage::{ command::{ - CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, + CommandRequest, GitRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, Presentation, help_text, otp_version_text, version_text, }, config::Config, + config::SshFingerprint, crypto::KeyInfo, document::{DocumentError, EntryDocument, EntryFieldId}, - git::{GitConflict, GitProgressPhase, GitSnapshot}, + git::{GitConflict, GitProgressPhase, GitSnapshot, SshHostKey}, otp::OtpCodeValidity, presentation::{ClipboardDisposition, ClipboardError, QrMatrix}, read::{FindResults, GrepResults, TreeModel}, @@ -223,6 +224,14 @@ pub enum AsyncPayload { conflicts: Vec, details: Option, }, + GitHostConfirmation { + request: GitRequest, + host_key: SshHostKey, + }, + GitPassphraseRequired { + request: GitRequest, + fingerprint: SshFingerprint, + }, OtpCodeFinished { entry: String, field: Option, @@ -301,6 +310,15 @@ pub enum AppEffect { }, AuthenticateWorkflow(Box), AuthenticateGit(ironstorage::command::GitRequest), + RetryGitAfterHostConfirmation { + request: GitRequest, + host_key: SshHostKey, + }, + RetryGitWithPassphrase { + request: GitRequest, + fingerprint: SshFingerprint, + passphrase: SecretBytes, + }, CancelGit, ResolveGit(Vec), AuthenticateOtp(OtpUiRequest), @@ -536,6 +554,14 @@ impl App { self.status = format!("{label} queued for secure-storage authentication…"); } + fn begin_git_retry(&mut self) { + self.git_pending = true; + self.workflow_pending = false; + self.workflow = None; + self.transition(Transition::Dismiss); + self.status = "Retrying Git operation with confirmed SSH input…".to_owned(); + } + pub fn remaining_lease(&self) -> Option { self.remaining_lease } @@ -861,6 +887,26 @@ impl App { self.focus = PaneFocus::Main; self.status = message; } + Ok(AsyncPayload::GitHostConfirmation { request, host_key }) => { + self.git_pending = false; + self.workflow = Some(WorkflowForm::ssh_host(request, host_key)); + self.workflow_pending = false; + self.status = + "Verify the SSH host fingerprint through a trusted channel before confirming." + .to_owned(); + self.transition(Transition::OpenDialog); + } + Ok(AsyncPayload::GitPassphraseRequired { + request, + fingerprint, + }) => { + self.git_pending = false; + self.workflow = Some(WorkflowForm::ssh_passphrase(request, fingerprint)); + self.workflow_pending = false; + self.status = + "Enter the encrypted SSH key passphrase; input stays masked.".to_owned(); + self.transition(Transition::OpenDialog); + } Ok(AsyncPayload::OtpCodeFinished { entry, field, @@ -1337,6 +1383,22 @@ impl App { } WorkflowInput::Lock => Some(self.dispatch(Action::Lock)), WorkflowInput::Submit => match self.workflow.as_ref()?.submission() { + Ok(WorkflowSubmission::SshHost { request, host_key }) => { + self.begin_git_retry(); + Some(AppEffect::RetryGitAfterHostConfirmation { request, host_key }) + } + Ok(WorkflowSubmission::SshPassphrase { + request, + fingerprint, + passphrase, + }) => { + self.begin_git_retry(); + Some(AppEffect::RetryGitWithPassphrase { + request, + fingerprint, + passphrase, + }) + } Ok(submission) => { self.workflow_pending = true; self.status = "Authenticating before applying the workflow…".to_owned(); @@ -2126,7 +2188,7 @@ impl App { fn git_phase_name(phase: GitProgressPhase) -> &'static str { match phase { GitProgressPhase::Validating => "validating repository", - GitProgressPhase::Authenticating => "requesting credentials", + GitProgressPhase::Authenticating => "verifying remote and credentials", GitProgressPhase::Receiving => "receiving remote objects", GitProgressPhase::Integrating => "integrating fetched changes", GitProgressPhase::Sending => "sending local objects", @@ -2744,4 +2806,40 @@ mod tests { assert!(rows.contains("••••••••")); assert!(!rows.contains("NEVER-RENDER")); } + + #[test] + fn typed_ssh_passphrase_request_opens_a_masked_cancellable_dialog() { + let mut app = App::new(); + app.git_pending = true; + let token = app.begin_request(); + let fingerprint = + SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + .expect("fingerprint"); + assert_eq!( + app.apply_result(AsyncResult { + token, + payload: Ok(AsyncPayload::GitPassphraseRequired { + request: GitRequest::Sync { + remote: Some("origin".to_owned()), + }, + fingerprint: fingerprint.clone(), + }), + }), + ResultDisposition::Applied + ); + assert_eq!(app.mode(), Mode::Dialog); + assert!(!app.git_pending()); + let rows = app.workflow().expect("SSH prompt").rows().join("\n"); + assert!(rows.contains(fingerprint.as_str())); + assert!(!rows.contains("secret")); + app.handle_workflow_input( + crossterm::event::KeyCode::Char('Q'), + crossterm::event::KeyModifiers::NONE, + ); + let rows = app.workflow().expect("SSH prompt").rows().join("\n"); + assert!(rows.contains("••••••••")); + assert!(!rows.contains('Q')); + assert!(matches!(app.dispatch(Action::Cancel), AppEffect::None)); + assert_eq!(app.mode(), Mode::Browser); + } } diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 02f019e..a9e3409 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -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) => { if let Some(coordinator) = authentication.as_mut() { coordinator.request_otp(request); @@ -557,7 +608,9 @@ fn apply_app_effect( let control = ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); *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( @@ -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 { + 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( config: &ironstorage::config::Config, request: ironstorage::command::GitRequest, mut credentials: Option, + ssh_passphrase: Option<( + ironstorage::config::SshFingerprint, + ironstorage::repository::SecretBytes, + )>, control: &ironstorage::git::GitOperationControl, ) -> Result { use ironstorage::{ command::{GitConfigRequest, GitRemoteRequest, GitRequest}, - git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport}, + git::{GitError, GitIdentity, GitRepository}, repository::{Repository, SecretBytes}, }; @@ -744,20 +854,20 @@ fn execute_git( } }, GitRequest::Fetch { remote } => { + let retry = GitRequest::Fetch { + remote: remote.clone(), + }; let configured = config .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 .as_ref() .ok_or_else(|| "Git fetch requires authentication".to_owned())?; - let outcome = git - .fetch_with_transport_controlled( - configured, - handle, - &EmbeddedFetchTransport, - control, - ) - .map_err(|error| error.to_string())?; + let provider = remote_credentials(handle, ssh_passphrase.as_ref()); + let outcome = match git.fetch_controlled(configured, &provider, control) { + Ok(outcome) => outcome, + Err(error) => return git_interaction_or_error(retry, error), + }; format!( "Fetched {}{}", outcome.remote(), @@ -769,19 +879,18 @@ fn execute_git( ) } GitRequest::Pull { remote, branch } => { + let retry = GitRequest::Pull { + remote: remote.clone(), + branch: branch.clone(), + }; let configured = config .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 .as_ref() .ok_or_else(|| "Git pull requires authentication".to_owned())?; - match git.pull_with_transport_controlled( - configured, - branch.as_deref(), - handle, - &EmbeddedFetchTransport, - control, - ) { + let provider = remote_credentials(handle, ssh_passphrase.as_ref()); + match git.pull_controlled(configured, branch.as_deref(), &provider, control) { Ok(outcome) => { changed_snapshot = true; format!("Git pull completed: {outcome:?}") @@ -801,25 +910,26 @@ fn execute_git( details: None, }); } - Err(error) => return Err(error.to_string()), + Err(error) => return git_interaction_or_error(retry, error), } } GitRequest::Push { remote, branch } => { + let retry = GitRequest::Push { + remote: remote.clone(), + branch: branch.clone(), + }; let configured = config .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 .as_ref() .ok_or_else(|| "Git push requires authentication".to_owned())?; - let outcome = git - .push_with_transport_controlled( - configured, - branch.as_deref(), - handle, - &ReqwestGitTransport, - control, - ) - .map_err(|error| error.to_string())?; + let provider = remote_credentials(handle, ssh_passphrase.as_ref()); + let outcome = + match git.push_controlled(configured, branch.as_deref(), &provider, control) { + Ok(outcome) => outcome, + Err(error) => return git_interaction_or_error(retry, error), + }; format!( "Pushed {} {}", outcome.remote(), @@ -827,19 +937,17 @@ fn execute_git( ) } GitRequest::Sync { remote } => { + let retry = GitRequest::Sync { + remote: remote.clone(), + }; let configured = config .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 .as_ref() .ok_or_else(|| "Git synchronization requires authentication".to_owned())?; - let pull = match git.pull_with_transport_controlled( - configured, - None, - handle, - &EmbeddedFetchTransport, - control, - ) { + let provider = remote_credentials(handle, ssh_passphrase.as_ref()); + let (pull, push) = match git.sync_controlled(configured, &provider, control) { Ok(outcome) => outcome, Err(GitError::MergeConflicts { conflicts }) => { let snapshot = git @@ -856,17 +964,8 @@ fn execute_git( 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; format!( "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 .report(ironstorage::git::GitProgressPhase::Refreshing) .map_err(|error| error.to_string())?; @@ -906,7 +1010,7 @@ fn execute_git_resolution( .map_err(|error| error.to_string())?; let configured = config .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()) .map_err(|error| error.to_string())?; let git = GitRepository::open(&repository, GitIdentity::ironstorage()) @@ -1117,7 +1221,7 @@ fn apply_authentication_event( ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); *git_control = Some(control.clone()); executor.submit(token, move || { - execute_git(&config, request, Some(handle), &control) + execute_git(&config, request, Some(handle), None, &control) }); } AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => { @@ -1203,6 +1307,9 @@ fn execute_workflow( let mut presentation = None; let mut refresh_tree = true; let (entry, mut status) = match submission { + WorkflowSubmission::SshHost { .. } | WorkflowSubmission::SshPassphrase { .. } => { + unreachable!("SSH interactions are routed before storage workflows") + } WorkflowSubmission::Init(request) => { let directory = request.path.as_deref().unwrap_or_default(); 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] fn terminal_ownership_loss_cancels_presentations_and_forces_relock() { let mut app = App::new(); diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 624b588..7ed20ab 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -974,7 +974,8 @@ fn git_lines(view: &crate::app::GitView) -> Vec> { ]; if let Some(remote) = snapshot.remote() { lines.push(Line::raw(format!( - "remote: {} {}", + "remote: {} {} {}", + remote.transport(), remote.name(), remote.url() ))); diff --git a/apps/tui/src/workflow.rs b/apps/tui/src/workflow.rs index 4280dc4..f959e48 100644 --- a/apps/tui/src/workflow.rs +++ b/apps/tui/src/workflow.rs @@ -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, 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); diff --git a/crates/apple/Cargo.toml b/crates/apple/Cargo.toml index b3aca25..6546119 100644 --- a/crates/apple/Cargo.toml +++ b/crates/apple/Cargo.toml @@ -13,5 +13,5 @@ name = "ironstorage_apple" crate-type = ["lib", "staticlib", "cdylib"] [dependencies] -ironstorage.workspace = true +ironstorage = { path = "../storage", default-features = false, features = ["full"] } uniffi.workspace = true diff --git a/crates/storage/src/authentication.rs b/crates/storage/src/authentication.rs index 7d1722f..a1934ad 100644 --- a/crates/storage/src/authentication.rs +++ b/crates/storage/src/authentication.rs @@ -406,6 +406,22 @@ impl AuthenticationHandle { .store_openpgp_passphrase(&reference, value) .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 SecretProvider for AuthenticationHandle { diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index f933165..0294a28 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -22,10 +22,9 @@ use crate::{ EntryFieldKind, }, git::{ - AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, - EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity, - GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome, - PushOutcome, ReqwestGitTransport, + AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitConflict, + GitConflictResolution, GitError, GitIdentity, GitOperationControl, GitProgressPhase, + GitRemoteCredentialOverride, GitRepository, GitSnapshot, PullOutcome, PushOutcome, }, kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter}, mutation::{MutationOutcome, TreeMutator}, @@ -434,6 +433,16 @@ impl DesktopStorage { handle: Option<&NativeAuthenticationHandle>, request: &DesktopGitRequest, control: &GitOperationControl, + ) -> Result { + 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 { control .report(GitProgressPhase::Validating) @@ -445,7 +454,7 @@ impl DesktopStorage { self.config.git_remote(None).ok_or_else(|| { DesktopError::new( 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(|| { DesktopError::new( DesktopErrorKind::Authentication, - "authentication is required for HTTPS Git credentials", + "authentication is required for Git credentials", ) })?; handle @@ -464,32 +473,53 @@ impl DesktopStorage { let (outcome, changed_tree) = match request { DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false), 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 - .pull_with_transport_controlled( - configured()?, - None, - authenticated()?, - &EmbeddedFetchTransport, - control, - ) + .pull_controlled(configured()?, None, &credentials, control) .map_err(DesktopError::git)?; (DesktopGitOutcome::Pulled(outcome), true) } 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 - .push_with_transport_controlled( - configured()?, - None, - authenticated()?, - &ReqwestGitTransport, - control, - ) + .push_controlled(configured()?, None, &credentials, control) .map_err(DesktopError::git)?; (DesktopGitOutcome::Pushed(outcome), false) } 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 - .sync_controlled(configured()?, authenticated()?, control) + .sync_controlled(configured()?, &credentials, control) .map_err(DesktopError::git)?; (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 { let repository = self.repository()?; let keys = self.keys()?; diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 94a30e2..0e59d18 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -155,6 +155,7 @@ impl GitConflictResolution { pub struct GitRemoteStatus { name: String, url: String, + transport: RemoteTransport, ahead: usize, behind: usize, } @@ -166,6 +167,9 @@ impl GitRemoteStatus { pub fn url(&self) -> &str { &self.url } + pub const fn transport(&self) -> RemoteTransport { + self.transport + } pub fn ahead(&self) -> usize { self.ahead } @@ -834,6 +838,69 @@ pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProv impl 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 GitCredentialProvider for GitRemoteCredentialOverride<'_, P> { + fn credential( + &self, + server: &ServerId, + application: &ApplicationId, + ) -> Result { + self.provider.credential(server, application) + } +} + +impl SshPassphraseProvider for GitRemoteCredentialOverride<'_, P> { + fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result { + 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 { fn advertise_receive_pack( &self, @@ -1718,6 +1785,20 @@ impl GitRepository { self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport) } + pub fn fetch_controlled( + &self, + configured: &GitRemote, + credentials: &impl GitRemoteCredentialProvider, + control: &GitOperationControl, + ) -> Result { + self.fetch_with_transport_controlled( + configured, + credentials, + &EmbeddedFetchTransport, + control, + ) + } + pub fn fetch_with_transport( &self, configured: &GitRemote, @@ -1921,6 +2002,22 @@ impl GitRepository { self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport) } + pub fn pull_controlled( + &self, + configured: &GitRemote, + branch: Option<&str>, + credentials: &impl GitRemoteCredentialProvider, + control: &GitOperationControl, + ) -> Result { + self.pull_with_transport_controlled( + configured, + branch, + credentials, + &EmbeddedFetchTransport, + control, + ) + } + pub fn pull_with_transport( &self, configured: &GitRemote, @@ -2065,7 +2162,7 @@ impl GitRepository { ) } - fn push_controlled( + pub fn push_controlled( &self, configured: &GitRemote, branch: Option<&str>, @@ -2349,6 +2446,7 @@ impl GitRepository { Ok(GitRemoteStatus { name: name.to_owned(), url: actual_url, + transport: configured.endpoint().transport(), ahead, behind, }) @@ -2404,6 +2502,7 @@ impl GitRepository { remote: GitRemoteStatus { name: name.to_owned(), url: actual_url, + transport: configured.endpoint().transport(), ahead, behind, }, @@ -4226,8 +4325,24 @@ mod ssh_push_tests; #[cfg(test)] mod tests { - use super::{GitIdentity, GitRepository}; - use crate::repository::Repository; + use super::{ + 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 { + Ok(SecretBytes::new(b"stored".to_vec())) + } + } #[test] fn embedded_identity_survives_local_config_updates() { @@ -4240,4 +4355,64 @@ mod tests { .expect("add remote"); 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()); + } } diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index e1c2936..6e104ff 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -512,6 +512,22 @@ impl SecretStore { 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. /// This lets Apple replace an item invalidated by biometric enrollment changes. pub(crate) fn store_openpgp_passphrase( diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs index ecbb6da..03960bc 100644 --- a/crates/storage/tests/secret_store.rs +++ b/crates/storage/tests/secret_store.rs @@ -289,6 +289,14 @@ fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> store.ssh_key_passphrase(&fingerprint)?.expose(), 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); assert!(matches!( store.ssh_key_passphrase(&fingerprint), diff --git a/docs/cli-parity.md b/docs/cli-parity.md index 063bb9c..e2229b5 100644 --- a/docs/cli-parity.md +++ b/docs/cli-parity.md @@ -68,10 +68,13 @@ test requires an upstream executable at runtime. The executable audit checks project Rust sources for process construction and permits it only in the editor adapter. Every project crate forbids unsafe Rust. -Git repository configuration rejects executable helpers and all non-HTTPS, -credential-bearing or rewritten remote forms before transport. Error and debug -models redact secret bytes; CLI presentation tests assert clipboard, QR, OTP -and generated values do not appear on unintended streams. +Git repository configuration rejects executable helpers, unsupported schemes, +credential-bearing URLs, and rewritten remote forms before transport. The +normal CLI build accepts typed HTTPS, `ssh://`, and scp-like remotes. Unknown SSH +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 `cargo metadata --locked`. Gix default features are disabled and only the diff --git a/docs/configuration.md b/docs/configuration.md index 0f83a03..c7949f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 configuration, try additional keys, prompt for passwords or 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; push and full pull-then-push synchronization use the matching embedded receive-pack channel. @@ -103,7 +104,10 @@ receive-pack channel. 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 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 are selected explicitly. diff --git a/docs/desktop-audit.md b/docs/desktop-audit.md index 01e5943..8799031 100644 --- a/docs/desktop-audit.md +++ b/docs/desktop-audit.md @@ -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. | | 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. | -| 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: diff --git a/docs/git-synchronization.md b/docs/git-synchronization.md index 4c3904f..2aa18f7 100644 --- a/docs/git-synchronization.md +++ b/docs/git-synchronization.md @@ -60,6 +60,15 @@ commands. SHA-1 host signatures and `ssh-rsa` authentication are excluded. Cancellation interrupts connection and authentication without changing Git, 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 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 chunked reference and pack streams, end-to-end clone/fetch/pull, command 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. diff --git a/docs/secure-secret-storage.md b/docs/secure-secret-storage.md index 2b2f4b5..40759c0 100644 --- a/docs/secure-secret-storage.md +++ b/docs/secure-secret-storage.md @@ -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. The same unlocked store implements the OpenPGP `SecretProvider`, HTTPS Git -`GitCredentialProvider`, and SSH `SshPassphraseProvider`. The CLI uses it for -terminal `show` and embedded Git, proving that protected keys and remote -authentication are resolved only through opaque references. Tests inject a -memory backend and never access a developer or CI user keyring. +`GitCredentialProvider`, and SSH `SshPassphraseProvider`. CLI, TUI, and desktop +pass one prompted SSH passphrase as zeroizing bytes for one retry. Storage binds +that override to the requested key fingerprint, and the authentication handle +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.