Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(git): improve SSH auth with key discovery #370

Merged
merged 5 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions crates/kftray-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ kftray-http-logs = { path = "../kftray-http-logs" }
netstat2 = { git = "https://github.com/hcavarsan/netstat2-rs" }
sysinfo = "0.33.1"
secrecy = "0.10.3"
git2 = "0.20.0"
git2 = { version = "0.17.1", features = ["ssh"] }
url = "2.5.3"

[dev-dependencies]
Expand All @@ -77,4 +77,4 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
289 changes: 237 additions & 52 deletions crates/kftray-tauri/src/commands/github.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::path::Path;
use std::path::PathBuf;

use git2::{
CertificateCheckStatus,
Expand Down Expand Up @@ -115,62 +116,228 @@ mod credentials {
}

pub fn get_git_credentials(url: &str, username: &str) -> Result<Cred, git2::Error> {
info!(
"Attempting to get credentials for URL: {} and username: {}",
url, username
);

// Try default config first
if let Ok(config) = get_git_config() {
if let Ok(cred) = try_credential_helper(&config, url, username) {
info!("Getting credentials for URL: {}", url);

if url.starts_with("git@") || url.starts_with("ssh://") {
if let Ok(cred) = try_ssh_authentication(username) {
return Ok(cred);
}
}
if let Ok(cred) = try_credential_helper(url, username) {
return Ok(cred);
}

// Fall back to stored credentials
try_stored_credentials(username)
try_stored_credentials()
}

fn get_git_config() -> Result<git2::Config, git2::Error> {
git2::Config::open_default()
.or_else(|_| Repository::open_from_env().and_then(|r| r.config()))
}
fn try_ssh_authentication(username: &str) -> Result<Cred, git2::Error> {
if let Ok(cred) = Cred::ssh_key_from_agent(username) {
info!("Successfully authenticated with SSH agent");
return Ok(cred);
}

if let Ok(config) = git2::Config::open_default() {
if let Ok(key_path_str) = config.get_string("core.sshCommand") {
if let Some(key_arg_pos) = key_path_str.find(" -i ") {
let key_path_start = key_arg_pos + 4;
let key_path_end = key_path_str[key_path_start..]
.find(' ')
.map(|pos| key_path_start + pos)
.unwrap_or(key_path_str.len());

let key_path_str = &key_path_str[key_path_start..key_path_end];
let key_path = Path::new(key_path_str);

if key_path.exists() {
info!("Trying SSH key from git config: {}", key_path.display());
if let Ok(cred) = Cred::ssh_key(username, None, key_path, None) {
info!("Successfully authenticated with SSH key from git config");
return Ok(cred);
}
}
}
}
}

let home_dir = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();

if let Ok(key_path_str) = std::env::var("SSH_KEY_PATH") {
let key_path = PathBuf::from(key_path_str);
if key_path.exists() {
info!("Trying SSH key from SSH_KEY_PATH: {}", key_path.display());
if let Ok(cred) = Cred::ssh_key(username, None, &key_path, None) {
info!("Successfully authenticated with SSH key from environment variable");
return Ok(cred);
}
}
}

let mut ssh_dirs = vec![PathBuf::from(&home_dir).join(".ssh")];

if let Ok(custom_ssh_dir) = std::env::var("SSH_DIR") {
ssh_dirs.push(PathBuf::from(custom_ssh_dir));
}

let key_names = ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];

for dir in &ssh_dirs {
if !dir.exists() || !dir.is_dir() {
continue;
}

for key_name in &key_names {
let key_path = dir.join(key_name);
if key_path.exists() {
info!("Trying standard SSH key: {}", key_path.display());
if let Ok(cred) = Cred::ssh_key(username, None, &key_path, None) {
info!(
"Successfully authenticated with standard SSH key: {}",
key_name
);
return Ok(cred);
}
}
}
}

for dir in ssh_dirs {
if !dir.exists() || !dir.is_dir() {
continue;
}

fn try_credential_helper(
config: &git2::Config, url: &str, username: &str,
) -> Result<Cred, git2::Error> {
match Cred::credential_helper(config, url, Some(username)) {
Ok(cred) => {
info!(
"Successfully retrieved credentials for username: {}",
username
);
Ok(cred)
info!("Scanning for SSH keys in: {}", dir.display());

let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) => {
info!("Failed to read SSH directory {}: {}", dir.display(), e);
continue;
}
};

for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};

let path = entry.path();

if !path.is_file() {
continue;
}

let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => continue,
};

if file_name.ends_with(".pub")
|| file_name == "known_hosts"
|| file_name == "authorized_keys"
|| file_name == "config"
{
continue;
}

info!("Trying potential SSH key: {}", path.display());
if let Ok(cred) = Cred::ssh_key(username, None, &path, None) {
info!(
"Successfully authenticated with SSH key: {}",
path.display()
);
return Ok(cred);
}
}
Err(e) => {
info!("Failed to get credentials for {}: {}", username, e);
Err(e)

let subdirs = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => continue,
};

for subdir_entry in subdirs {
let subdir_entry = match subdir_entry {
Ok(entry) => entry,
Err(_) => continue,
};

let subdir_path = subdir_entry.path();
if !subdir_path.is_dir() {
continue;
}

let subdir_entries = match std::fs::read_dir(&subdir_path) {
Ok(entries) => entries,
Err(_) => continue,
};

for file_entry in subdir_entries {
let file_entry = match file_entry {
Ok(entry) => entry,
Err(_) => continue,
};

let file_path = file_entry.path();
if !file_path.is_file() {
continue;
}

let file_name = match file_path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => continue,
};

if file_name.ends_with(".pub") {
continue;
}

info!(
"Trying potential SSH key from subdirectory: {}",
file_path.display()
);
if let Ok(cred) = Cred::ssh_key(username, None, &file_path, None) {
info!(
"Successfully authenticated with SSH key: {}",
file_path.display()
);
return Ok(cred);
}
}
}
}

Err(git2::Error::from_str(
"SSH authentication failed: no valid SSH keys found",
))
}

fn try_credential_helper(url: &str, username: &str) -> Result<Cred, git2::Error> {
if let Ok(config) = git2::Config::open_default() {
match Cred::credential_helper(&config, url, Some(username)) {
Ok(cred) => {
info!("Successfully retrieved credentials from OS credential store");
Ok(cred)
}
Err(e) => {
info!("Credential helper failed: {}", e);
Err(e)
}
}
} else {
Err(git2::Error::from_str("Failed to open git config"))
}
}

fn try_stored_credentials(_username: &str) -> Result<Cred, git2::Error> {
let stored_credentials = try_credentials_from_file();
info!(
"Found {} stored credentials to try",
stored_credentials.len()
);
fn try_stored_credentials() -> Result<Cred, git2::Error> {
let credentials = try_credentials_from_file();

for (stored_username, password) in stored_credentials {
info!(
"Trying stored credentials for username: {}",
stored_username
);
if let Ok(cred) = Cred::userpass_plaintext(&stored_username, &password) {
info!(
"Successfully created credentials for username: {}",
stored_username
);
for (username, password) in credentials {
info!("Trying stored credentials for username: {}", username);
if let Ok(cred) = Cred::userpass_plaintext(&username, &password) {
info!("Successfully authenticated with stored credentials");
return Ok(cred);
}
}
Expand Down Expand Up @@ -201,21 +368,39 @@ fn setup_git_callbacks(
// Only set up credentials callback if authentication is needed
if use_system_credentials || github_token.is_some() {
let token = github_token.clone();

let attempts = std::sync::atomic::AtomicUsize::new(0);

callbacks.credentials(move |url, username_from_url, allowed_types| {
let current_attempt = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if current_attempt >= 3 {
return Err(git2::Error::from_str(
"Authentication failed after 3 attempts",
));
}

info!(
"Auth attempt - URL: {}, Username: {:?}, Allowed types: {:?}",
url, username_from_url, allowed_types
"Auth attempt {} - URL: {}, Username: {:?}, Allowed types: {:?}",
current_attempt + 1,
url,
username_from_url,
allowed_types
);

if let Some(token) = &token {
// Use GitHub token if provided
Cred::userpass_plaintext("git", token)
} else if use_system_credentials {
// Use system credentials only if explicitly requested
let initial_username = username_from_url.unwrap_or("git");
credentials::get_git_credentials(url, initial_username)
let is_https_url = url.starts_with("https://");

if is_https_url
&& token.is_some()
&& allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT)
{
info!("Using token authentication for HTTPS");
return Cred::userpass_plaintext("git", token.as_ref().unwrap());
}

if use_system_credentials {
let username = username_from_url.unwrap_or("git");
credentials::get_git_credentials(url, username)
} else {
// This shouldn't be reached due to the outer if condition
Err(git2::Error::from_str("No authentication method configured"))
}
});
Expand Down
Loading