Reuse native macOS Git credentials
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled

This commit is contained in:
2026-08-26 18:53:55 +02:00
parent 59cd8951f6
commit b93ae852e0
10 changed files with 327 additions and 36 deletions

View File

@@ -455,6 +455,20 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> GitCredentialProvider
.map_err(|_| GitError::CredentialsUnavailable)?;
self.shared.store.credential(server, application)
}
fn credential_for(&self, remote: &crate::config::GitRemote) -> Result<GitCredential, GitError> {
let _operation = self
.shared
.operation()
.map_err(|_| GitError::CredentialsUnavailable)?;
self.shared
.expire_if_needed()
.map_err(|_| GitError::CredentialsUnavailable)?;
self.shared
.with_active(self.generation, |_| ())
.map_err(|_| GitError::CredentialsUnavailable)?;
self.shared.store.credential_for(remote)
}
}
impl<B: SecretStoreBackend, C: AuthenticationClock> SshPassphraseProvider

View File

@@ -1587,7 +1587,7 @@ struct RawGit {
remotes: Vec<RawGitRemote>,
}
#[derive(Deserialize)]
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGitRemote {
name: String,
@@ -1700,7 +1700,7 @@ fn validate_config(
};
let configured_git_remotes = raw.git.remotes;
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
let git_remotes = match repository_git_remotes(&vault)? {
let git_remotes = match repository_git_remotes(&vault, &configured_git_remotes, base)? {
Some(remotes) => remotes,
None => validate_remotes(configured_git_remotes, Some(base))?,
};
@@ -1727,7 +1727,11 @@ fn validate_config(
}
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
fn repository_git_remotes(vault: &Path) -> Result<Option<Vec<GitRemote>>, ConfigError> {
fn repository_git_remotes(
vault: &Path,
configured_remotes: &[RawGitRemote],
config_base: &Path,
) -> Result<Option<Vec<GitRemote>>, ConfigError> {
let path = vault.join(".git/config");
if !path.is_file() {
return Ok(None);
@@ -1760,21 +1764,45 @@ fn repository_git_remotes(vault: &Path) -> Result<Option<Vec<GitRemote>>, Config
name: name.to_owned(),
})?;
let remote = match endpoint {
RemoteEndpoint::Https(ref endpoint) => GitRemote::https(
name,
&url,
stable_identifier(
"server",
format!(
"{}://{}:{}",
endpoint.scheme(),
endpoint.host_str().unwrap_or_default(),
endpoint.port_or_known_default().unwrap_or(443)
)
.as_bytes(),
),
stable_identifier("repository", url.as_bytes()),
)?,
RemoteEndpoint::Https(ref https_endpoint) => {
let credential_ids = configured_remotes
.iter()
.find(|configured| {
RemoteEndpoint::parse(&configured.url)
.is_ok_and(|configured| configured == endpoint)
})
.map(|configured| {
let mut validated =
validate_remotes(vec![configured.clone()], Some(config_base))?;
let configured = validated.remove(0);
let (server, application) = configured.https_credentials().ok_or(
ConfigError::InvalidField {
field: "git.remotes.https_credentials",
},
)?;
Ok::<_, ConfigError>((
server.as_str().to_owned(),
application.as_str().to_owned(),
))
})
.transpose()?
.unwrap_or_else(|| {
(
stable_identifier(
"server",
format!(
"{}://{}:{}",
https_endpoint.scheme(),
https_endpoint.host_str().unwrap_or_default(),
https_endpoint.port_or_known_default().unwrap_or(443)
)
.as_bytes(),
),
stable_identifier("repository", https_endpoint.as_str().as_bytes()),
)
});
GitRemote::https(name, &url, credential_ids.0, credential_ids.1)?
}
RemoteEndpoint::Ssh(_) => GitRemote::ssh(name, &url)?,
};
remotes.push(remote);

View File

@@ -828,6 +828,11 @@ pub trait GitCredentialProvider {
server: &ServerId,
application: &ApplicationId,
) -> Result<GitCredential, GitError>;
fn credential_for(&self, remote: &GitRemote) -> Result<GitCredential, GitError> {
let (_, server, application) = require_https_remote(remote)?;
self.credential(server, application)
}
}
pub trait SshPassphraseProvider {
@@ -873,6 +878,10 @@ impl<P: GitCredentialProvider> GitCredentialProvider for GitRemoteCredentialOver
) -> Result<GitCredential, GitError> {
self.provider.credential(server, application)
}
fn credential_for(&self, remote: &GitRemote) -> Result<GitCredential, GitError> {
self.provider.credential_for(remote)
}
}
impl<P: SshPassphraseProvider> SshPassphraseProvider for GitRemoteCredentialOverride<'_, P> {
@@ -1856,8 +1865,7 @@ impl GitRepository {
) -> Result<bool, GitError> {
match configured.endpoint() {
RemoteEndpoint::Https(_) => {
let (_, server_id, application_id) = require_https_remote(configured)?;
let credential = credentials.credential(server_id, application_id)?;
let credential = credentials.credential_for(configured)?;
self.fetch_embedded_https(configured, &credential, control)
}
RemoteEndpoint::Ssh(_) => {
@@ -2171,8 +2179,8 @@ impl GitRepository {
control.checkpoint(GitProgressPhase::Authenticating)?;
match configured.endpoint() {
RemoteEndpoint::Https(_) => {
let (url, server_id, application_id) = require_https_remote(configured)?;
let credential = credentials.credential(server_id, application_id)?;
let (url, _, _) = require_https_remote(configured)?;
let credential = credentials.credential_for(configured)?;
let transport = ReqwestGitTransport;
let mut client = ReceivePackClient::Https {
url,
@@ -2228,9 +2236,9 @@ impl GitRepository {
control: &GitOperationControl,
) -> Result<PushOutcome, GitError> {
let context = self.prepare_push(configured, branch, control)?;
let (url, server_id, application_id) = require_https_remote(configured)?;
let (url, _, _) = require_https_remote(configured)?;
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential = credentials.credential(server_id, application_id)?;
let credential = credentials.credential_for(configured)?;
let mut client = ReceivePackClient::Https {
url,
credential,

View File

@@ -355,6 +355,12 @@ pub trait SecretStoreBackend: Send + Sync {
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<(), SecretStoreError>;
fn platform_https_git_credential(
&self,
_endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
Ok(None)
}
fn lock(&self) -> Result<(), SecretStoreError> {
Ok(())
}
@@ -848,19 +854,44 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
let record = self
.retrieve_git_record(server, application)
.map_err(git_provider_error)?;
let Some(account) = record.reference.account() else {
git_credential_from_record(&record)
}
fn credential_for(&self, remote: &crate::config::GitRemote) -> Result<GitCredential, GitError> {
let Some((server, application)) = remote.https_credentials() else {
return Err(GitError::CredentialsUnavailable);
};
if record.value.expose().contains(&b'\n')
|| record.value.expose().contains(&b'\r')
|| record.value.expose().contains(&0)
let endpoint = remote
.endpoint()
.as_https()
.ok_or(GitError::CredentialsUnavailable)?;
match self
.backend
.platform_https_git_credential(endpoint)
.map_err(git_provider_error)?
{
return Err(GitError::CredentialsUnavailable);
Some((account, password)) => GitCredential::new(account, password.expose().to_vec()),
None => self
.retrieve_git_record(server, application)
.map_err(git_provider_error)
.and_then(|record| git_credential_from_record(&record)),
}
GitCredential::new(account, record.value.expose().to_vec())
}
}
fn git_credential_from_record(record: &SecretRecord) -> Result<GitCredential, GitError> {
let Some(account) = record.reference.account() else {
return Err(GitError::CredentialsUnavailable);
};
if record.value.expose().contains(&b'\n')
|| record.value.expose().contains(&b'\r')
|| record.value.expose().contains(&0)
{
return Err(GitError::CredentialsUnavailable);
}
GitCredential::new(account, record.value.expose().to_vec())
}
impl<B: SecretStoreBackend> SshPassphraseProvider for SecretStore<B> {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
self.retrieve(&SecretReference::ssh_key_passphrase(fingerprint.clone()))

View File

@@ -20,6 +20,15 @@ use security_framework::{
},
};
#[cfg(target_os = "macos")]
use security_framework::{
item::{ItemClass, ItemSearchOptions, Limit},
os::macos::{
keychain::SecKeychain,
passwords::{SecAuthenticationType, SecProtocolType},
},
};
use keyring_core::{CredentialStore, Entry};
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
@@ -196,6 +205,95 @@ impl SecretStoreBackend for NativeSecretBackend {
.delete_credential()
.map_err(map_error)
}
fn platform_https_git_credential(
&self,
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
#[cfg(target_os = "macos")]
{
macos_https_git_credential(endpoint)
}
#[cfg(not(target_os = "macos"))]
{
let _ = endpoint;
Ok(None)
}
}
}
#[cfg(target_os = "macos")]
fn macos_https_git_credential(
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
let host = endpoint
.host_str()
.ok_or(SecretStoreError::InvalidReference)?;
let path = endpoint.path();
let mut search = ItemSearchOptions::new();
search
.class(ItemClass::internet_password())
.load_attributes(true)
.limit(Limit::All);
let mut candidates = search
.search()
.map_err(map_security_error)?
.into_iter()
.filter_map(|item| item.simplify_dict())
.filter_map(|attributes| {
let stored_path = attributes.get("path").map_or("", String::as_str);
if attributes.get("srvr").map(String::as_str) == Some(host)
&& attributes.get("ptcl").map(String::as_str) == Some("htps")
&& (stored_path.is_empty() || stored_path == path)
{
attributes
.get("acct")
.map(|account| (account.clone(), stored_path.to_owned()))
} else {
None
}
})
.collect::<Vec<_>>();
candidates.sort_unstable();
candidates.dedup();
let [(account, credential_path)] = candidates.as_slice() else {
return if candidates.is_empty() {
Err(SecretStoreError::Missing)
} else {
Err(SecretStoreError::Corrupted)
};
};
let keychain = SecKeychain::default().map_err(map_security_error)?;
let (password, _) = keychain
.find_internet_password(
host,
None,
account,
credential_path,
endpoint.port(),
SecProtocolType::HTTPS,
SecAuthenticationType::Default,
)
.or_else(|error| {
if endpoint.port().is_some() && error.code() == -25300 {
keychain.find_internet_password(
host,
None,
account,
credential_path,
None,
SecProtocolType::HTTPS,
SecAuthenticationType::Default,
)
} else {
Err(error)
}
})
.map_err(map_security_error)?;
Ok(Some((
account.clone(),
SecretBytes::new(password.as_ref().to_vec()),
)))
}
#[cfg(any(target_os = "ios", target_os = "macos"))]

View File

@@ -133,6 +133,49 @@ key_material = "keys"
Ok(())
}
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
#[test]
fn repository_https_remote_reuses_credentials_only_for_the_same_url() -> TestResult {
let fixture = ConfigurationFixture::new()?;
let vault = fixture.temporary.path().join("cwd/vault");
fs::create_dir_all(vault.join(".git"))?;
fs::write(
vault.join(".git/config"),
r#"[remote "origin"]
url = https://git.example.test/team/store.git
[remote "mirror"]
url = https://mirror.example.test/team/store.git
"#,
)?;
fixture.write_explicit(
r#"
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
[[git.remotes]]
name = "legacy-name"
url = "https://git.example.test/team/store.git"
server_id = "existing-server"
application_id = "existing-application"
"#,
)?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
let origin = config.git_remote(Some("origin")).expect("origin remote");
let (server, application) = origin.https_credentials().expect("HTTPS credentials");
assert_eq!(server.as_str(), "existing-server");
assert_eq!(application.as_str(), "existing-application");
let mirror = config.git_remote(Some("mirror")).expect("mirror remote");
let (server, application) = mirror.https_credentials().expect("HTTPS credentials");
assert_ne!(server.as_str(), "existing-server");
assert_ne!(application.as_str(), "existing-application");
Ok(())
}
#[test]
fn git_commit_identity_defaults_validates_and_persists() -> TestResult {
let fixture = ConfigurationFixture::new()?;

View File

@@ -32,6 +32,8 @@ struct MemoryState {
create_fault: Option<SecretStoreError>,
retrieves: usize,
protections: Vec<SecretProtection>,
platform_https: Option<(String, SecretBytes)>,
platform_https_requests: Vec<String>,
}
#[derive(Clone, Default)]
@@ -60,6 +62,13 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").protections.clone()
}
fn with_platform_https(account: &str, password: &[u8]) -> Self {
let backend = Self::default();
backend.0.lock().expect("test mutex").platform_https =
Some((account.to_owned(), SecretBytes::new(password.to_vec())));
backend
}
fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> {
match state.fault.take() {
Some(error) => Err(error),
@@ -138,6 +147,22 @@ impl SecretStoreBackend for MemoryBackend {
.ok_or(SecretStoreError::Missing)
}
fn platform_https_git_credential(
&self,
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
state
.platform_https_requests
.push(endpoint.as_str().to_owned());
Ok(state.platform_https.as_ref().map(|(account, password)| {
(
account.clone(),
SecretBytes::new(password.expose().to_vec()),
)
}))
}
fn lock(&self) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)
@@ -149,6 +174,42 @@ impl SecretStoreBackend for MemoryBackend {
}
}
#[test]
fn endpoint_lookup_uses_the_native_https_credential_before_private_storage() -> TestResult {
let backend = MemoryBackend::with_platform_https("alice", b"native-token");
let store = store(backend.clone());
store.unlock()?;
let remote = GitRemote::https(
"origin",
"https://example.test/team/store.git",
"server",
"application",
)?;
let (server, application) = remote.https_credentials().expect("HTTPS credentials");
store.store_https_git_credential(
server,
application,
"private-account",
SecretBytes::new(b"private-token".to_vec()),
)?;
let retrieves_before = backend.retrieves();
let credential = store.credential_for(&remote)?;
assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"native-token");
assert_eq!(backend.retrieves(), retrieves_before);
assert_eq!(
backend
.0
.lock()
.expect("test mutex")
.platform_https_requests,
["https://example.test/team/store.git".to_owned()]
);
Ok(())
}
#[derive(Clone, Default)]
struct MemoryPrompt(Arc<Mutex<MemoryPromptState>>);