Enable SSH remotes in native frontends (#117)
This commit is contained in:
@@ -13,5 +13,5 @@ name = "ironstorage_apple"
|
||||
crate-type = ["lib", "staticlib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ironstorage.workspace = true
|
||||
ironstorage = { path = "../storage", default-features = false, features = ["full"] }
|
||||
uniffi.workspace = true
|
||||
|
||||
@@ -406,6 +406,22 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
|
||||
.store_openpgp_passphrase(&reference, value)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Persist a passphrase that a completed SSH operation proved could
|
||||
/// unlock and authenticate the configured private key.
|
||||
pub fn persist_verified_ssh_passphrase(
|
||||
&self,
|
||||
fingerprint: &crate::config::SshFingerprint,
|
||||
value: SecretBytes,
|
||||
) -> Result<(), AuthenticationError> {
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.expire_if_needed()?;
|
||||
self.shared.with_active(self.generation, |_| ())?;
|
||||
self.shared
|
||||
.store
|
||||
.persist_verified_ssh_passphrase(fingerprint, value)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
|
||||
|
||||
@@ -22,10 +22,9 @@ use crate::{
|
||||
EntryFieldKind,
|
||||
},
|
||||
git::{
|
||||
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter,
|
||||
EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||
PushOutcome, ReqwestGitTransport,
|
||||
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitConflict,
|
||||
GitConflictResolution, GitError, GitIdentity, GitOperationControl, GitProgressPhase,
|
||||
GitRemoteCredentialOverride, GitRepository, GitSnapshot, PullOutcome, PushOutcome,
|
||||
},
|
||||
kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter},
|
||||
mutation::{MutationOutcome, TreeMutator},
|
||||
@@ -434,6 +433,16 @@ impl DesktopStorage {
|
||||
handle: Option<&NativeAuthenticationHandle>,
|
||||
request: &DesktopGitRequest,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<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> {
|
||||
control
|
||||
.report(GitProgressPhase::Validating)
|
||||
@@ -445,7 +454,7 @@ impl DesktopStorage {
|
||||
self.config.git_remote(None).ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Configuration,
|
||||
"no HTTPS Git remote is configured",
|
||||
"no Git remote is configured",
|
||||
)
|
||||
})
|
||||
};
|
||||
@@ -453,7 +462,7 @@ impl DesktopStorage {
|
||||
let handle = handle.ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Authentication,
|
||||
"authentication is required for HTTPS Git credentials",
|
||||
"authentication is required for Git credentials",
|
||||
)
|
||||
})?;
|
||||
handle
|
||||
@@ -464,32 +473,53 @@ impl DesktopStorage {
|
||||
let (outcome, changed_tree) = match request {
|
||||
DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false),
|
||||
DesktopGitRequest::Pull => {
|
||||
let handle = authenticated()?;
|
||||
let credentials = ssh_passphrase.map_or_else(
|
||||
|| GitRemoteCredentialOverride::new(handle),
|
||||
|(fingerprint, passphrase)| {
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
handle,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
);
|
||||
let outcome = git
|
||||
.pull_with_transport_controlled(
|
||||
configured()?,
|
||||
None,
|
||||
authenticated()?,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
.pull_controlled(configured()?, None, &credentials, control)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Pulled(outcome), true)
|
||||
}
|
||||
DesktopGitRequest::Push => {
|
||||
let handle = authenticated()?;
|
||||
let credentials = ssh_passphrase.map_or_else(
|
||||
|| GitRemoteCredentialOverride::new(handle),
|
||||
|(fingerprint, passphrase)| {
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
handle,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
);
|
||||
let outcome = git
|
||||
.push_with_transport_controlled(
|
||||
configured()?,
|
||||
None,
|
||||
authenticated()?,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
)
|
||||
.push_controlled(configured()?, None, &credentials, control)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Pushed(outcome), false)
|
||||
}
|
||||
DesktopGitRequest::Sync => {
|
||||
let handle = authenticated()?;
|
||||
let credentials = ssh_passphrase.map_or_else(
|
||||
|| GitRemoteCredentialOverride::new(handle),
|
||||
|(fingerprint, passphrase)| {
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
handle,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
);
|
||||
let (pull, push) = git
|
||||
.sync_controlled(configured()?, authenticated()?, control)
|
||||
.sync_controlled(configured()?, &credentials, control)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Synchronized { pull, push }, true)
|
||||
}
|
||||
@@ -515,6 +545,17 @@ impl DesktopStorage {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssh")]
|
||||
pub fn confirm_ssh_host(&self, host_key: &crate::git::SshHostKey) -> Result<(), DesktopError> {
|
||||
let remote = self.config.git_remote(None).ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Configuration,
|
||||
"no Git remote is configured",
|
||||
)
|
||||
})?;
|
||||
crate::git::confirm_ssh_host(remote, host_key).map_err(DesktopError::git)
|
||||
}
|
||||
|
||||
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
|
||||
let repository = self.repository()?;
|
||||
let keys = self.keys()?;
|
||||
|
||||
@@ -155,6 +155,7 @@ impl GitConflictResolution {
|
||||
pub struct GitRemoteStatus {
|
||||
name: String,
|
||||
url: String,
|
||||
transport: RemoteTransport,
|
||||
ahead: usize,
|
||||
behind: usize,
|
||||
}
|
||||
@@ -166,6 +167,9 @@ impl GitRemoteStatus {
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
pub const fn transport(&self) -> RemoteTransport {
|
||||
self.transport
|
||||
}
|
||||
pub fn ahead(&self) -> usize {
|
||||
self.ahead
|
||||
}
|
||||
@@ -834,6 +838,69 @@ pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProv
|
||||
|
||||
impl<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 {
|
||||
fn advertise_receive_pack(
|
||||
&self,
|
||||
@@ -1718,6 +1785,20 @@ impl GitRepository {
|
||||
self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
|
||||
}
|
||||
|
||||
pub fn fetch_controlled(
|
||||
&self,
|
||||
configured: &GitRemote,
|
||||
credentials: &impl GitRemoteCredentialProvider,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<FetchOutcome, GitError> {
|
||||
self.fetch_with_transport_controlled(
|
||||
configured,
|
||||
credentials,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fetch_with_transport(
|
||||
&self,
|
||||
configured: &GitRemote,
|
||||
@@ -1921,6 +2002,22 @@ impl GitRepository {
|
||||
self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
|
||||
}
|
||||
|
||||
pub fn pull_controlled(
|
||||
&self,
|
||||
configured: &GitRemote,
|
||||
branch: Option<&str>,
|
||||
credentials: &impl GitRemoteCredentialProvider,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<PullOutcome, GitError> {
|
||||
self.pull_with_transport_controlled(
|
||||
configured,
|
||||
branch,
|
||||
credentials,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pull_with_transport(
|
||||
&self,
|
||||
configured: &GitRemote,
|
||||
@@ -2065,7 +2162,7 @@ impl GitRepository {
|
||||
)
|
||||
}
|
||||
|
||||
fn push_controlled(
|
||||
pub fn push_controlled(
|
||||
&self,
|
||||
configured: &GitRemote,
|
||||
branch: Option<&str>,
|
||||
@@ -2349,6 +2446,7 @@ impl GitRepository {
|
||||
Ok(GitRemoteStatus {
|
||||
name: name.to_owned(),
|
||||
url: actual_url,
|
||||
transport: configured.endpoint().transport(),
|
||||
ahead,
|
||||
behind,
|
||||
})
|
||||
@@ -2404,6 +2502,7 @@ impl GitRepository {
|
||||
remote: GitRemoteStatus {
|
||||
name: name.to_owned(),
|
||||
url: actual_url,
|
||||
transport: configured.endpoint().transport(),
|
||||
ahead,
|
||||
behind,
|
||||
},
|
||||
@@ -4226,8 +4325,24 @@ mod ssh_push_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GitIdentity, GitRepository};
|
||||
use crate::repository::Repository;
|
||||
use super::{
|
||||
GitError, GitIdentity, GitRemoteCredentialOverride, GitRepository, SshPassphraseProvider,
|
||||
};
|
||||
use crate::{
|
||||
config::SshFingerprint,
|
||||
repository::{Repository, SecretBytes},
|
||||
};
|
||||
|
||||
struct Passphrases;
|
||||
|
||||
impl SshPassphraseProvider for Passphrases {
|
||||
fn ssh_key_passphrase(
|
||||
&self,
|
||||
_fingerprint: &SshFingerprint,
|
||||
) -> Result<SecretBytes, GitError> {
|
||||
Ok(SecretBytes::new(b"stored".to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_identity_survives_local_config_updates() {
|
||||
@@ -4240,4 +4355,64 @@ mod tests {
|
||||
.expect("add remote");
|
||||
assert!(repository.repository.committer().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_ssh_passphrase_override_is_bound_to_its_fingerprint() {
|
||||
let selected = SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
|
||||
.expect("fingerprint");
|
||||
let other = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
||||
.expect("fingerprint");
|
||||
let supplied = SecretBytes::new(b"prompted".to_vec());
|
||||
let credentials =
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(&Passphrases, &selected, &supplied);
|
||||
assert_eq!(
|
||||
credentials
|
||||
.ssh_key_passphrase(&selected)
|
||||
.expect("override")
|
||||
.expose(),
|
||||
b"prompted"
|
||||
);
|
||||
assert_eq!(
|
||||
credentials
|
||||
.ssh_key_passphrase(&other)
|
||||
.expect("stored fallback")
|
||||
.expose(),
|
||||
b"stored"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssh")]
|
||||
#[test]
|
||||
fn host_confirmation_is_bound_to_the_configured_endpoint() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let known_hosts = temporary.path().join("known_hosts");
|
||||
let fingerprint =
|
||||
SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
|
||||
.expect("fingerprint");
|
||||
let authentication = crate::config::SshRemoteAuthentication::agent(
|
||||
fingerprint.clone(),
|
||||
None,
|
||||
known_hosts.clone(),
|
||||
)
|
||||
.expect("authentication");
|
||||
let remote = crate::config::GitRemote::ssh_with_authentication(
|
||||
"origin",
|
||||
"ssh://git@example.test:2222/team/store.git",
|
||||
authentication,
|
||||
)
|
||||
.expect("remote");
|
||||
let observed = super::SshHostKey::new(
|
||||
"other.example.test".to_owned(),
|
||||
2222,
|
||||
"ssh-ed25519".to_owned(),
|
||||
fingerprint,
|
||||
"invalid-key".to_owned(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
super::confirm_ssh_host(&remote, &observed),
|
||||
Err(GitError::SshProtocolFailed)
|
||||
);
|
||||
assert!(!known_hosts.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +512,22 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store an SSH key passphrase only after a caller has proved it by
|
||||
/// completing SSH public-key authentication successfully.
|
||||
pub fn persist_verified_ssh_passphrase(
|
||||
&self,
|
||||
fingerprint: &SshFingerprint,
|
||||
value: SecretBytes,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
|
||||
let candidate = SecretBytes::new(value.expose().to_vec());
|
||||
match self.create(&reference, candidate) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(SecretStoreError::AlreadyExists) => self.replace(&reference, value),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a verified OpenPGP passphrase without first reading the old item.
|
||||
/// This lets Apple replace an item invalidated by biometric enrollment changes.
|
||||
pub(crate) fn store_openpgp_passphrase(
|
||||
|
||||
@@ -289,6 +289,14 @@ fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() ->
|
||||
store.ssh_key_passphrase(&fingerprint)?.expose(),
|
||||
b"protected-passphrase"
|
||||
);
|
||||
store.persist_verified_ssh_passphrase(
|
||||
&fingerprint,
|
||||
SecretBytes::new(b"verified-replacement".to_vec()),
|
||||
)?;
|
||||
assert_eq!(
|
||||
store.ssh_key_passphrase(&fingerprint)?.expose(),
|
||||
b"verified-replacement"
|
||||
);
|
||||
backend.fail_next(SecretStoreError::Denied);
|
||||
assert!(matches!(
|
||||
store.ssh_key_passphrase(&fingerprint),
|
||||
|
||||
Reference in New Issue
Block a user