fix: resolve runtime panic in Linux keychain integration (#32)

* fix: resolve runtime panic in Linux keychain integration

- Convert Linux keychain functions from sync (rt.block_on) to async
- Remove nested runtime panic when called from async context
- Make keychain API consistent across platforms (macOS, Linux, fallback)
- Propagate async through config loading and CLI commands

Fixes panic on Linux during 'ironclaw onboard' at Step 2 (Security).

* fix: await async Config::from_env in test_heartbeat example
This commit is contained in:
bkutasi
2026-02-12 00:15:41 +00:00
committed by GitHub
parent 23de75d75b
commit 45f547c711
9 changed files with 158 additions and 173 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ async fn main() -> anyhow::Result<()> {
println!("=== Heartbeat Integration Test ===\n");
// 1. Load config
let config = Config::from_env().map_err(|e| anyhow::anyhow!("Config: {}", e))?;
let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("Config: {}", e))?;
println!("[1/6] Config loaded");
println!(" heartbeat.enabled = {}", config.heartbeat.enabled);
println!(
+1 -1
View File
@@ -71,7 +71,7 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
/// Bootstrap a DB connection for config commands.
async fn connect_store() -> anyhow::Result<crate::history::Store> {
let config = crate::config::Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?;
let config = crate::config::Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?;
let store = crate::history::Store::new(&config.database).await?;
store.run_migrations().await?;
Ok(store)
+2 -2
View File
@@ -467,7 +467,7 @@ const DEFAULT_USER_ID: &str = "default";
/// Try to connect to the database store for DB-backed config.
async fn connect_store() -> Option<Store> {
let config = Config::from_env().ok()?;
let config = Config::from_env().await.ok()?;
let store = Store::new(&config.database).await.ok()?;
store.run_migrations().await.ok()?;
Some(store)
@@ -496,7 +496,7 @@ async fn save_servers(
/// Initialize and return the secrets store.
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
let config = Config::from_env()?;
let config = Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
+1 -1
View File
@@ -47,7 +47,7 @@ pub async fn run_status_command() -> anyhow::Result<()> {
print!(" Secrets: ");
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|| crate::secrets::keychain::has_master_key();
|| crate::secrets::keychain::has_master_key().await;
if secrets_configured {
println!("configured ({:?})", settings.secrets_master_key_source);
} else {
+1 -1
View File
@@ -715,7 +715,7 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
println!();
// Initialize secrets store
let config = Config::from_env()?;
let config = Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
+23 -18
View File
@@ -53,7 +53,7 @@ impl Config {
}
};
Self::build(bootstrap, &db_settings)
Self::build(bootstrap, &db_settings).await
}
/// Load configuration from environment variables only (no database).
@@ -61,15 +61,15 @@ impl Config {
/// Used during early startup before the database is connected,
/// and by CLI commands that don't have DB access.
/// Falls back to legacy `settings.json` on disk if present.
pub fn from_env() -> Result<Self, ConfigError> {
pub async fn from_env() -> Result<Self, ConfigError> {
let _ = dotenvy::dotenv();
let bootstrap = crate::bootstrap::BootstrapConfig::load();
let settings = Settings::load();
Self::build(&bootstrap, &settings)
Self::build(&bootstrap, &settings).await
}
/// Build config from bootstrap + settings (shared by from_env and from_db).
fn build(
async fn build(
bootstrap: &crate::bootstrap::BootstrapConfig,
settings: &Settings,
) -> Result<Self, ConfigError> {
@@ -82,7 +82,7 @@ impl Config {
agent: AgentConfig::resolve(settings)?,
safety: SafetyConfig::resolve()?,
wasm: WasmConfig::resolve()?,
secrets: SecretsConfig::resolve(bootstrap)?,
secrets: SecretsConfig::resolve(bootstrap).await?,
builder: BuilderModeConfig::resolve()?,
heartbeat: HeartbeatConfig::resolve(settings)?,
routines: RoutineConfig::resolve()?,
@@ -604,27 +604,32 @@ impl std::fmt::Debug for SecretsConfig {
}
impl SecretsConfig {
fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
async fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
use crate::settings::KeySource;
let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? {
(Some(SecretString::from(env_key)), KeySource::Env)
} else {
match bootstrap.secrets_master_key_source {
KeySource::Keychain => match crate::secrets::keychain::get_master_key() {
Ok(key_bytes) => {
let key_hex: String =
key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
(Some(SecretString::from(key_hex)), KeySource::Keychain)
}
Err(_) => {
tracing::warn!(
"Secrets configured for keychain but key not found. \
KeySource::Keychain => {
// Try to load from OS keychain (async on Linux)
match crate::secrets::keychain::get_master_key().await {
Ok(key_bytes) => {
let key_hex: String =
key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
(Some(SecretString::from(key_hex)), KeySource::Keychain)
}
Err(_) => {
// Keychain configured but key not found
// This might happen if keychain was cleared
tracing::warn!(
"Secrets configured for keychain but key not found. \
Run 'ironclaw onboard' to reconfigure."
);
(None, KeySource::None)
);
(None, KeySource::None)
}
}
},
}
KeySource::Env => {
tracing::warn!(
"Secrets configured for env var but SECRETS_MASTER_KEY not set."
+5 -5
View File
@@ -84,7 +84,7 @@ async fn main() -> anyhow::Result<()> {
// Memory commands need database (and optionally embeddings)
let _ = dotenvy::dotenv();
let config = Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?;
let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?;
let store = ironclaw::history::Store::new(&config.database).await?;
store.run_migrations().await?;
@@ -240,7 +240,7 @@ async fn main() -> anyhow::Result<()> {
// Enhanced first-run detection
if !cli.no_onboard {
if let Some(reason) = check_onboard_needed() {
if let Some(reason) = check_onboard_needed().await {
println!("Onboarding needed: {}", reason);
println!();
let mut wizard = SetupWizard::new();
@@ -252,7 +252,7 @@ async fn main() -> anyhow::Result<()> {
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
// Load initial config from env + disk (before DB is available)
let mut config = match Config::from_env() {
let mut config = match Config::from_env().await {
Ok(c) => c,
Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => {
eprintln!("Configuration error: Missing required setting '{}'", key);
@@ -1043,7 +1043,7 @@ async fn main() -> anyhow::Result<()> {
/// Check if onboarding is needed and return the reason.
///
/// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise.
fn check_onboard_needed() -> Option<&'static str> {
async fn check_onboard_needed() -> Option<&'static str> {
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
// Database not configured (and not in env)
@@ -1054,7 +1054,7 @@ fn check_onboard_needed() -> Option<&'static str> {
// Secrets not configured (and not in env)
if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None
&& std::env::var("SECRETS_MASTER_KEY").is_err()
&& !ironclaw::secrets::keychain::has_master_key()
&& !ironclaw::secrets::keychain::has_master_key().await
{
// Only require secrets setup if user hasn't explicitly disabled it
// For now, we don't require it for first run
+117 -139
View File
@@ -52,7 +52,7 @@ mod platform {
use super::*;
/// Store the master key in the macOS Keychain.
pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
// Convert to hex for storage (keychain prefers strings)
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
@@ -61,7 +61,7 @@ mod platform {
}
/// Retrieve the master key from the macOS Keychain.
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
pub async fn get_master_key() -> Result<Vec<u8>, SecretError> {
let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| {
SecretError::KeychainError(format!("Failed to get from keychain: {}", e))
})?;
@@ -74,14 +74,14 @@ mod platform {
}
/// Delete the master key from the macOS Keychain.
pub fn delete_master_key() -> Result<(), SecretError> {
pub async fn delete_master_key() -> Result<(), SecretError> {
delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| {
SecretError::KeychainError(format!("Failed to delete from keychain: {}", e))
})
}
/// Check if a master key exists in the keychain.
pub fn has_master_key() -> bool {
pub async fn has_master_key() -> bool {
get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok()
}
}
@@ -97,163 +97,141 @@ mod platform {
use super::*;
/// Store the master key in the Linux secret service (GNOME Keyring, KWallet).
pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
let collection = ss.get_default_collection().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to get collection: {}", e))
pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
// Unlock if needed
if collection.is_locked().await.unwrap_or(true) {
collection.unlock().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to unlock collection: {}", e))
})?;
}
let collection = ss.get_default_collection().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to get collection: {}", e))
})?;
// Convert to hex for storage
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
// Unlock if needed
if collection.is_locked().await.unwrap_or(true) {
collection.unlock().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to unlock collection: {}", e))
})?;
}
collection
.create_item(
&format!("{} master key", SERVICE_NAME),
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
key_hex.as_bytes(),
true, // Replace if exists
"text/plain",
)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to create secret: {}", e))
})?;
// Convert to hex for storage
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
Ok(())
})
collection
.create_item(
&format!("{} master key", SERVICE_NAME),
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
key_hex.as_bytes(),
true, // Replace if exists
"text/plain",
)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to create secret: {}", e))
})?;
Ok(())
}
/// Retrieve the master key from the Linux secret service.
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
pub async fn get_master_key() -> Result<Vec<u8>, SecretError> {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
let item = items
.unlocked
.first()
.or(items.locked.first())
.ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?;
// Unlock if needed
if item.is_locked().await.unwrap_or(true) {
item.unlock()
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
.map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?;
}
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
let secret = item
.get_secret()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?;
let item = items
.unlocked
.first()
.or(items.locked.first())
.ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?;
let hex_str = String::from_utf8(secret)
.map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?;
// Unlock if needed
if item.is_locked().await.unwrap_or(true) {
item.unlock()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?;
}
let secret = item
.get_secret()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?;
let hex_str = String::from_utf8(secret)
.map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?;
hex_to_bytes(&hex_str)
})
hex_to_bytes(&hex_str)
}
/// Delete the master key from the Linux secret service.
pub fn delete_master_key() -> Result<(), SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
pub async fn delete_master_key() -> Result<(), SecretError> {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
for item in items.unlocked.iter().chain(items.locked.iter()) {
item.delete()
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
.map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?;
}
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
for item in items.unlocked.iter().chain(items.locked.iter()) {
item.delete()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?;
}
Ok(())
})
Ok(())
}
/// Check if a master key exists in the secret service.
pub fn has_master_key() -> bool {
let rt = match tokio::runtime::Handle::try_current() {
Ok(rt) => rt,
pub async fn has_master_key() -> bool {
let ss = match SecretService::connect(EncryptionType::Dh).await {
Ok(ss) => ss,
Err(_) => return false,
};
rt.block_on(async {
let ss = match SecretService::connect(EncryptionType::Dh).await {
Ok(ss) => ss,
Err(_) => return false,
};
let items = match ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
{
Ok(items) => items,
Err(_) => return false,
};
let items = match ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
{
Ok(items) => items,
Err(_) => return false,
};
!items.unlocked.is_empty() || !items.locked.is_empty()
})
!items.unlocked.is_empty() || !items.locked.is_empty()
}
}
@@ -265,25 +243,25 @@ mod platform {
mod platform {
use super::*;
pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> {
pub async fn store_master_key(_key: &[u8]) -> Result<(), SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(),
))
}
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
pub async fn get_master_key() -> Result<Vec<u8>, SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(),
))
}
pub fn delete_master_key() -> Result<(), SecretError> {
pub async fn delete_master_key() -> Result<(), SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform".to_string(),
))
}
pub fn has_master_key() -> bool {
pub async fn has_master_key() -> bool {
false
}
}
+7 -5
View File
@@ -266,7 +266,7 @@ impl SetupWizard {
async fn step_security(&mut self) -> Result<(), SetupError> {
// Check current configuration
let env_key_exists = std::env::var("SECRETS_MASTER_KEY").is_ok();
let keychain_key_exists = crate::secrets::keychain::has_master_key();
let keychain_key_exists = crate::secrets::keychain::has_master_key().await;
if env_key_exists {
print_info("Secrets master key found in SECRETS_MASTER_KEY environment variable.");
@@ -304,9 +304,11 @@ impl SetupWizard {
print_info("Generating master key...");
let key = crate::secrets::keychain::generate_master_key();
crate::secrets::keychain::store_master_key(&key).map_err(|e| {
SetupError::Config(format!("Failed to store in keychain: {}", e))
})?;
crate::secrets::keychain::store_master_key(&key)
.await
.map_err(|e| {
SetupError::Config(format!("Failed to store in keychain: {}", e))
})?;
// Also create crypto instance
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
@@ -550,7 +552,7 @@ impl SetupWizard {
// Try to load master key from keychain or env
let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") {
env_key
} else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key() {
} else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key().await {
keychain_key.iter().map(|b| format!("{:02x}", b)).collect()
} else {
return Err(SetupError::Config(