Implement SSH identity authentication

This commit is contained in:
2026-08-25 19:52:47 +02:00
parent f636f3b551
commit 5dbda4bbd2
14 changed files with 1964 additions and 60 deletions

View File

@@ -701,7 +701,7 @@ url = "https://example.test/team/store.git"
}
#[test]
fn ssh_remote_configuration_round_trips_without_https_credentials() -> TestResult {
fn ssh_remote_configuration_round_trips_with_secret_free_authentication() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
@@ -712,12 +712,27 @@ key_material = "keys"
[[git.remotes]]
name = "origin"
url = "git@example.test:team/store.git"
ssh_identity_file = "keys/id_ed25519"
ssh_known_hosts_file = "known_hosts"
"#,
)?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
let remote = &config.git_remotes()[0];
assert_eq!(remote.url(), "git@example.test:team/store.git");
assert!(remote.https_credentials().is_none());
let authentication = remote.ssh_authentication().expect("SSH authentication");
assert_eq!(
authentication.identity().key_file(),
Some(
fs::canonicalize(fixture.temporary.path())?
.join("cwd/config/keys/id_ed25519")
.as_path()
)
);
assert_eq!(
authentication.known_hosts_file(),
fs::canonicalize(fixture.temporary.path())?.join("cwd/config/known_hosts")
);
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
@@ -725,6 +740,70 @@ url = "git@example.test:team/store.git"
let persisted = fs::read_to_string(fixture.explicit_path())?;
assert!(!persisted.contains("server_id"));
assert!(!persisted.contains("application_id"));
assert!(!persisted.contains("passphrase"));
assert!(persisted.contains("ssh_identity_file"));
assert!(persisted.contains("ssh_known_hosts_file"));
Ok(())
}
#[test]
fn ssh_authentication_requires_exactly_one_identity_source() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "ssh://git@example.test/team/store.git"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(&fixture.explicit_path()))
.expect_err("SSH identity is required"),
ConfigError::InvalidField {
field: "git.remotes.ssh_authentication"
}
);
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "ssh://git@example.test/team/store.git"
ssh_agent_fingerprint = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
ssh_agent_socket = "agent.sock"
ssh_known_hosts_file = "known_hosts"
"#,
)?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
let authentication = config.git_remotes()[0]
.ssh_authentication()
.expect("SSH authentication");
assert_eq!(
authentication
.identity()
.agent_fingerprint()
.expect("agent fingerprint")
.as_str(),
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
);
assert_eq!(
authentication.identity().agent_socket(),
Some(
fs::canonicalize(fixture.temporary.path())?
.join("cwd/config/agent.sock")
.as_path()
)
);
Ok(())
}

View File

@@ -11,9 +11,9 @@ use std::{
};
use ironstorage::{
config::{ConfigLoader, GitRemote},
config::{ConfigLoader, GitRemote, SshFingerprint},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
git::{GitCredentialProvider as _, GitError},
git::{GitCredentialProvider as _, GitError, SshPassphraseProvider as _},
repository::{EncryptedEntry, SecretBytes},
secret_store::{
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator,
@@ -274,6 +274,41 @@ fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestRe
Ok(())
}
#[test]
fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> TestResult {
let backend = MemoryBackend::default();
let store = store(backend.clone());
store.unlock()?;
let fingerprint = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
store.create(
&reference,
SecretBytes::new(b"protected-passphrase".to_vec()),
)?;
assert_eq!(
store.ssh_key_passphrase(&fingerprint)?.expose(),
b"protected-passphrase"
);
backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
store.ssh_key_passphrase(&fingerprint),
Err(GitError::SshKeyPassphraseDenied { fingerprint: denied }) if denied == fingerprint
));
backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!(
store.ssh_key_passphrase(&fingerprint),
Err(GitError::SshKeyPassphraseCancelled { fingerprint: cancelled })
if cancelled == fingerprint
));
let missing = SshFingerprint::parse("SHA256:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE")?;
assert!(matches!(
store.ssh_key_passphrase(&missing),
Err(GitError::SshKeyPassphraseUnavailable { fingerprint }) if fingerprint == missing
));
assert!(!format!("{reference:?}").contains("protected-passphrase"));
Ok(())
}
#[test]
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
let backend = MemoryBackend::default();