use anyhow::{Context, Result};
use security_framework::passwords::{
PasswordOptions, delete_generic_password, generic_password, set_generic_password,
};
use std::path::Path;
use zeroize::Zeroizing;
const KEYCHAIN_SERVICE_NAME: &str = "bssh-ssh-key-passphrase";
const MAX_PASSPHRASE_LENGTH: usize = 8192;
pub async fn store_passphrase(key_path: impl AsRef<Path>, passphrase: &str) -> Result<()> {
let key_path = key_path.as_ref();
if passphrase.len() > MAX_PASSPHRASE_LENGTH {
anyhow::bail!(
"Passphrase too long ({} bytes, max {} bytes)",
passphrase.len(),
MAX_PASSPHRASE_LENGTH
);
}
let canonical_path = tokio::fs::canonicalize(key_path)
.await
.with_context(|| format!("Failed to resolve SSH key path: {key_path:?}"))?;
let metadata = tokio::fs::metadata(&canonical_path)
.await
.with_context(|| format!("Failed to read SSH key file metadata: {canonical_path:?}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let current_uid = unsafe { libc::getuid() };
let file_uid = metadata.uid();
if file_uid != current_uid {
anyhow::bail!(
"Security error: SSH key file is not owned by current user (file uid: {file_uid}, current uid: {current_uid}). \
Only the owner of an SSH key should be able to store its passphrase."
);
}
let mode = metadata.mode();
if mode & 0o044 != 0 {
tracing::warn!(
"SSH key file has overly permissive permissions: {:o}. \
Consider restricting with: chmod 600 {}",
mode & 0o777,
canonical_path.display()
);
}
}
let account_name = canonical_path.to_str().ok_or_else(|| {
anyhow::anyhow!("SSH key path contains invalid UTF-8: {canonical_path:?}")
})?;
tracing::debug!("Storing passphrase in Keychain for key: {account_name}");
let passphrase_bytes = Zeroizing::new(passphrase.as_bytes().to_vec());
let service_name = KEYCHAIN_SERVICE_NAME.to_string();
let account_name_owned = account_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
set_generic_password(&service_name, &account_name_owned, &passphrase_bytes).with_context(
|| {
"Failed to store passphrase in Keychain. \
This may happen if Keychain access is denied or the Keychain is locked."
},
)?;
tracing::info!("Successfully stored passphrase in Keychain for key: {account_name_owned}");
Ok(())
})
.await
.context("Keychain storage task panicked")??;
Ok(())
}
pub async fn retrieve_passphrase(key_path: impl AsRef<Path>) -> Result<Option<Zeroizing<String>>> {
let key_path = key_path.as_ref();
let canonical_path = tokio::fs::canonicalize(key_path)
.await
.with_context(|| format!("Failed to resolve SSH key path: {key_path:?}"))?;
let account_name = canonical_path.to_str().ok_or_else(|| {
anyhow::anyhow!("SSH key path contains invalid UTF-8: {canonical_path:?}")
})?;
tracing::debug!("Retrieving passphrase from Keychain for key: {account_name}");
let service_name = KEYCHAIN_SERVICE_NAME.to_string();
let account_name_owned = account_name.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<Option<Zeroizing<Vec<u8>>>> {
let options = PasswordOptions::new_generic_password(&service_name, &account_name_owned);
match generic_password(options) {
Ok(passphrase_bytes) => {
tracing::info!(
"Successfully retrieved passphrase from Keychain for key: {account_name_owned}"
);
Ok(Some(Zeroizing::new(passphrase_bytes)))
}
Err(err) => {
let err_msg = format!("{err:?}");
if err_msg.contains("errSecItemNotFound") || err_msg.contains("-25300") {
tracing::debug!(
"No passphrase found in Keychain for key: {account_name_owned}"
);
Ok(None)
} else {
Err(anyhow::anyhow!(
"Failed to retrieve passphrase from Keychain: {err}\n\
This may happen if Keychain access is denied or the Keychain is locked."
))
}
}
}
})
.await
.context("Keychain retrieval task panicked")??;
if let Some(passphrase_bytes) = result {
let passphrase_str = Zeroizing::new(
String::from_utf8(passphrase_bytes.to_vec())
.context("Passphrase stored in Keychain is not valid UTF-8")?,
);
Ok(Some(passphrase_str))
} else {
Ok(None)
}
}
pub async fn delete_passphrase(key_path: impl AsRef<Path>) -> Result<()> {
let key_path = key_path.as_ref();
let canonical_path = tokio::fs::canonicalize(key_path)
.await
.with_context(|| format!("Failed to resolve SSH key path: {key_path:?}"))?;
let account_name = canonical_path.to_str().ok_or_else(|| {
anyhow::anyhow!("SSH key path contains invalid UTF-8: {canonical_path:?}")
})?;
tracing::debug!("Deleting passphrase from Keychain for key: {account_name}");
let service_name = KEYCHAIN_SERVICE_NAME.to_string();
let account_name_owned = account_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
match delete_generic_password(&service_name, &account_name_owned) {
Ok(()) => {
tracing::info!(
"Successfully deleted passphrase from Keychain for key: {account_name_owned}"
);
Ok(())
}
Err(err) => {
let err_msg = format!("{err:?}");
if err_msg.contains("errSecItemNotFound") || err_msg.contains("-25300") {
tracing::debug!(
"No passphrase found in Keychain for key: {account_name_owned}"
);
Ok(())
} else {
Err(anyhow::anyhow!(
"Failed to delete passphrase from Keychain: {err}\n\
This may happen if Keychain access is denied."
))
}
}
}
})
.await
.context("Keychain deletion task panicked")??;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_store_and_retrieve_passphrase() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("test_key");
tokio::fs::write(
&key_path,
"-----BEGIN PRIVATE KEY-----\nfake key\n-----END PRIVATE KEY-----",
)
.await
.unwrap();
let test_passphrase = "test-passphrase-12345";
store_passphrase(&key_path, test_passphrase)
.await
.expect("Failed to store passphrase");
let retrieved = retrieve_passphrase(&key_path)
.await
.expect("Failed to retrieve passphrase");
assert!(retrieved.is_some(), "Passphrase should be found");
assert_eq!(
retrieved.as_ref().unwrap().as_str(),
test_passphrase,
"Retrieved passphrase should match stored passphrase"
);
delete_passphrase(&key_path)
.await
.expect("Failed to delete passphrase");
let after_delete = retrieve_passphrase(&key_path)
.await
.expect("Failed to check after deletion");
assert!(after_delete.is_none(), "Passphrase should be deleted");
}
#[tokio::test]
async fn test_retrieve_nonexistent_passphrase() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("nonexistent_key");
tokio::fs::write(&key_path, "fake key content")
.await
.unwrap();
let result = retrieve_passphrase(&key_path).await;
assert!(
result.is_ok(),
"Should not error on non-existent passphrase"
);
assert!(
result.unwrap().is_none(),
"Should return None for non-existent passphrase"
);
}
#[tokio::test]
async fn test_delete_nonexistent_passphrase() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("nonexistent_key");
tokio::fs::write(&key_path, "fake key content")
.await
.unwrap();
let result = delete_passphrase(&key_path).await;
assert!(
result.is_ok(),
"Deleting non-existent passphrase should succeed silently"
);
}
#[tokio::test]
async fn test_update_existing_passphrase() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("update_test_key");
tokio::fs::write(&key_path, "fake key content")
.await
.unwrap();
let first_passphrase = "first-passphrase";
let second_passphrase = "second-passphrase";
store_passphrase(&key_path, first_passphrase).await.unwrap();
store_passphrase(&key_path, second_passphrase)
.await
.unwrap();
let retrieved = retrieve_passphrase(&key_path).await.unwrap();
assert_eq!(
retrieved.as_ref().unwrap().as_str(),
second_passphrase,
"Should have updated to second passphrase"
);
delete_passphrase(&key_path).await.unwrap();
}
#[tokio::test]
async fn test_passphrase_too_long() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("test_key");
tokio::fs::write(&key_path, "fake key content")
.await
.unwrap();
let long_passphrase = "a".repeat(MAX_PASSPHRASE_LENGTH + 1);
let result = store_passphrase(&key_path, &long_passphrase).await;
assert!(result.is_err(), "Should error on too-long passphrase");
assert!(
result.unwrap_err().to_string().contains("too long"),
"Error should mention passphrase length"
);
}
#[tokio::test]
async fn test_invalid_key_path() {
let result = store_passphrase("/nonexistent/path/to/key", "passphrase").await;
assert!(result.is_err(), "Should error on non-existent key path");
}
#[tokio::test]
async fn test_passphrase_zeroization() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("zeroize_test_key");
tokio::fs::write(&key_path, "fake key content")
.await
.unwrap();
let passphrase = "secret-passphrase";
store_passphrase(&key_path, passphrase).await.unwrap();
let retrieved = retrieve_passphrase(&key_path).await.unwrap().unwrap();
assert_eq!(retrieved.as_str(), passphrase);
drop(retrieved);
delete_passphrase(&key_path).await.unwrap();
}
}