Enable SSH remotes in native frontends (#117)
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
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(),
|
handle.as_ref(),
|
||||||
&request,
|
&request,
|
||||||
&control,
|
&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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 outcome = git
|
let handle = authenticated()?;
|
||||||
.pull_with_transport_controlled(
|
let credentials = ssh_passphrase.map_or_else(
|
||||||
configured()?,
|
|| GitRemoteCredentialOverride::new(handle),
|
||||||
None,
|
|(fingerprint, passphrase)| {
|
||||||
authenticated()?,
|
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||||
&EmbeddedFetchTransport,
|
handle,
|
||||||
control,
|
fingerprint,
|
||||||
|
passphrase,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let outcome = git
|
||||||
|
.pull_controlled(configured()?, None, &credentials, control)
|
||||||
.map_err(DesktopError::git)?;
|
.map_err(DesktopError::git)?;
|
||||||
(DesktopGitOutcome::Pulled(outcome), true)
|
(DesktopGitOutcome::Pulled(outcome), true)
|
||||||
}
|
}
|
||||||
DesktopGitRequest::Push => {
|
DesktopGitRequest::Push => {
|
||||||
let outcome = git
|
let handle = authenticated()?;
|
||||||
.push_with_transport_controlled(
|
let credentials = ssh_passphrase.map_or_else(
|
||||||
configured()?,
|
|| GitRemoteCredentialOverride::new(handle),
|
||||||
None,
|
|(fingerprint, passphrase)| {
|
||||||
authenticated()?,
|
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||||
&ReqwestGitTransport,
|
handle,
|
||||||
control,
|
fingerprint,
|
||||||
|
passphrase,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let outcome = git
|
||||||
|
.push_controlled(configured()?, None, &credentials, 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()?;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user