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

@@ -11,13 +11,17 @@ secure-secret-storage orchestration. Git remotes are typed HTTPS or optional
SSH endpoints; reject local, helper, executable, and unknown transports before SSH endpoints; reject local, helper, executable, and unknown transports before
entering Git transport code. Builds without the `ssh` feature must still parse entering Git transport code. Builds without the `ssh` feature must still parse
SSH endpoints and return a typed unsupported-transport error before connection SSH endpoints and return a typed unsupported-transport error before connection
or repository mutation. or repository mutation. When a password-store repository contains Git remotes,
those names and URLs are authoritative; use application configuration only as
the fallback for a repository without remotes.
The SSH feature must keep its algorithm allowlist, strict known-host checking, The SSH feature must keep its algorithm allowlist, strict known-host checking,
single configured identity, bounded channel/diagnostic limits, and ambiguous single configured identity, bounded channel/diagnostic limits, and ambiguous
push outcome rules in `crates/storage`. Do not add OpenSSH configuration, push outcome rules in `crates/storage`. The in-process SSH configuration reader
proxy commands, password or keyboard-interactive authentication, host-key may honor only the connection directives `Host`, `HostName`, `User`, `Port`,
bypasses, or frontend transport policy. and `IdentityFile`. Do not add `Include`, `Match`, proxy commands, password or
keyboard-interactive authentication, host-key bypasses, or frontend transport
policy.
The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect
input, invoke the Rust API, and present Rust-provided state. They must not input, invoke the Rust API, and present Rust-provided state. They must not

View File

@@ -455,6 +455,20 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> GitCredentialProvider
.map_err(|_| GitError::CredentialsUnavailable)?; .map_err(|_| GitError::CredentialsUnavailable)?;
self.shared.store.credential(server, application) 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 impl<B: SecretStoreBackend, C: AuthenticationClock> SshPassphraseProvider

View File

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

View File

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

View File

@@ -355,6 +355,12 @@ pub trait SecretStoreBackend: Send + Sync {
locator: &SecretLocator, locator: &SecretLocator,
protection: SecretProtection, protection: SecretProtection,
) -> Result<(), SecretStoreError>; ) -> Result<(), SecretStoreError>;
fn platform_https_git_credential(
&self,
_endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
Ok(None)
}
fn lock(&self) -> Result<(), SecretStoreError> { fn lock(&self) -> Result<(), SecretStoreError> {
Ok(()) Ok(())
} }
@@ -848,19 +854,44 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
let record = self let record = self
.retrieve_git_record(server, application) .retrieve_git_record(server, application)
.map_err(git_provider_error)?; .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); return Err(GitError::CredentialsUnavailable);
}; };
if record.value.expose().contains(&b'\n') let endpoint = remote
|| record.value.expose().contains(&b'\r') .endpoint()
|| record.value.expose().contains(&0) .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> { impl<B: SecretStoreBackend> SshPassphraseProvider for SecretStore<B> {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> { fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
self.retrieve(&SecretReference::ssh_key_passphrase(fingerprint.clone())) 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 keyring_core::{CredentialStore, Entry};
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
@@ -196,6 +205,95 @@ impl SecretStoreBackend for NativeSecretBackend {
.delete_credential() .delete_credential()
.map_err(map_error) .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"))] #[cfg(any(target_os = "ios", target_os = "macos"))]

View File

@@ -133,6 +133,49 @@ key_material = "keys"
Ok(()) 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] #[test]
fn git_commit_identity_defaults_validates_and_persists() -> TestResult { fn git_commit_identity_defaults_validates_and_persists() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;

View File

@@ -32,6 +32,8 @@ struct MemoryState {
create_fault: Option<SecretStoreError>, create_fault: Option<SecretStoreError>,
retrieves: usize, retrieves: usize,
protections: Vec<SecretProtection>, protections: Vec<SecretProtection>,
platform_https: Option<(String, SecretBytes)>,
platform_https_requests: Vec<String>,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
@@ -60,6 +62,13 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").protections.clone() 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> { fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> {
match state.fault.take() { match state.fault.take() {
Some(error) => Err(error), Some(error) => Err(error),
@@ -138,6 +147,22 @@ impl SecretStoreBackend for MemoryBackend {
.ok_or(SecretStoreError::Missing) .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> { fn lock(&self) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state) 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)] #[derive(Clone, Default)]
struct MemoryPrompt(Arc<Mutex<MemoryPromptState>>); struct MemoryPrompt(Arc<Mutex<MemoryPromptState>>);

View File

@@ -32,7 +32,8 @@ repository mutation instead of treating its configuration as malformed.
## HTTPS transport ## HTTPS transport
HTTPS credentials are requested with the configured server ID and application HTTPS credentials are requested with the configured server ID and application
ID and remain outside Git configuration. ID and remain outside Git configuration. macOS resolves them solely through
the standard Internet-password credential for the same HTTPS endpoint.
Fetch uses the embedded Rust smart-HTTP client with an explicit credential Fetch uses the embedded Rust smart-HTTP client with an explicit credential
callback, so Git's credential cascade is never entered. Push implements the callback, so Git's credential cascade is never entered. Push implements the

View File

@@ -29,7 +29,10 @@ The platform-selection code is isolated in
- macOS uses legacy Keychain for command-line-compatible device-unlocked - macOS uses legacy Keychain for command-line-compatible device-unlocked
credentials and Protected Data for `RequireUserPresence`; iOS uses Protected credentials and Protected Data for `RequireUserPresence`; iOS uses Protected
Data. User cancellation is mapped from the native Security Framework status. Data. macOS HTTPS synchronization uses only the unique standard
Internet-password credential matching the remote endpoint; it does not use
an IronStorage-specific Git credential locator. User cancellation is mapped
from the native Security Framework status.
- Windows uses Credential Manager. Store operations are serialized because the - Windows uses Credential Manager. Store operations are serialized because the
upstream adapter documents unreliable same-entry sequencing across threads. upstream adapter documents unreliable same-entry sequencing across threads.
- Linux uses Secret Service through zbus with the Rust cryptography feature. It - Linux uses Secret Service through zbus with the Rust cryptography feature. It