Files
optimclaw/src/secrets/keychain.rs
T
13e000dc20 fix(wasm): use per-engine cache dirs on Windows to avoid file lock error (#624)
* fix(wasm): use per-engine cache dirs on Windows to avoid file lock error (#448)

On Windows, multiple wasmtime Engine instances sharing the default
compilation cache directory hit OS error 33 (ERROR_LOCK_VIOLATION)
because Windows holds exclusive file locks on memory-mapped cache
files. This is especially triggered when the Telegram channel WASM
module is loaded at startup and then hot-activated via the Extensions
UI.

Fix by giving each engine its own cache subdirectory on Windows
(~/.cache/ironclaw/wasmtime-tools/ and wasmtime-channels/). On
Unix the shared default cache continues to work as before.

Also adds Windows CI jobs (cargo check + clippy across all feature
flag combinations) to catch Windows-specific issues going forward.

Closes #448

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: silence Windows clippy warnings for platform-gated code

Gate PathBuf import behind #[cfg(unix)] in container.rs (only used
in Unix socket path), suppress unused_mut on conflicts Vec in
channels.rs (mutations are platform-gated), and add cfg gates on
keychain constants and hex_to_bytes that are only used on macOS/Linux.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: escape directory path in TOML cache config to prevent injection

Use double-quoted TOML strings with backslash and double-quote
escaping for the cache directory path, preventing breakage or
injection when paths contain special characters (e.g. single
quotes on Unix, backslashes on Windows).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: resolve cargo fmt formatting errors

Fix import ordering in container.rs and line wrapping in runtime.rs
to pass the CI formatting check.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(ci): restore Path import for all platforms, keep PathBuf unix-only

Path is used in non-cfg-gated functions (lines 148, 244) so it must
be available on all platforms. Only PathBuf is unix-specific.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-06 23:31:58 +00:00

319 lines
10 KiB
Rust

//! OS keychain integration for secrets master key storage.
//!
//! Provides platform-specific keychain support:
//! - macOS: security-framework (Keychain Services)
//! - Linux: secret-service (GNOME Keyring, KWallet)
//!
//! # Example
//!
//! ```ignore
//! use ironclaw::secrets::keychain::{store_master_key, get_master_key, delete_master_key};
//!
//! // Generate and store a new master key
//! let key = generate_master_key();
//! store_master_key(&key)?;
//!
//! // Later, retrieve it
//! let key = get_master_key()?;
//! ```
use crate::secrets::SecretError;
/// Service name for keychain entries.
#[cfg(any(target_os = "macos", target_os = "linux"))]
const SERVICE_NAME: &str = "ironclaw";
/// Account name for the master key.
#[cfg(any(target_os = "macos", target_os = "linux"))]
const MASTER_KEY_ACCOUNT: &str = "master_key";
/// Generate a random 32-byte master key.
pub fn generate_master_key() -> Vec<u8> {
use rand::RngCore;
use rand::rngs::OsRng;
let mut key = vec![0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
/// Generate a master key as a hex string.
pub fn generate_master_key_hex() -> String {
let bytes = generate_master_key();
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
// ============================================================================
// macOS implementation using security-framework
// ============================================================================
#[cfg(target_os = "macos")]
mod platform {
use security_framework::passwords::{
delete_generic_password, get_generic_password, set_generic_password,
};
use super::*;
/// Store the master key in the macOS Keychain.
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();
set_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT, key_hex.as_bytes())
.map_err(|e| SecretError::KeychainError(format!("Failed to store in keychain: {}", e)))
}
/// Retrieve the master key from the macOS Keychain.
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))
})?;
// Parse hex string back to bytes
let hex_str = String::from_utf8(password)
.map_err(|_| SecretError::KeychainError("Invalid UTF-8 in keychain".to_string()))?;
hex_to_bytes(&hex_str)
}
/// Delete the master key from the macOS Keychain.
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 async fn has_master_key() -> bool {
get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok()
}
}
// ============================================================================
// Linux implementation using secret-service
// ============================================================================
#[cfg(target_os = "linux")]
mod platform {
use secret_service::{EncryptionType, SecretService};
use super::*;
/// Store the master key in the Linux secret service (GNOME Keyring, KWallet).
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))
})?;
let collection = ss
.get_default_collection()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get collection: {}", 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))
})?;
}
// Convert to hex for storage
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
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 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))
})?;
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 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)
}
/// Delete the master key from the Linux secret service.
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))
})?;
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(())
}
/// Check if a master key exists in the secret service.
pub async fn has_master_key() -> bool {
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,
};
!items.unlocked.is_empty() || !items.locked.is_empty()
}
}
// ============================================================================
// Fallback for unsupported platforms
// ============================================================================
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
mod platform {
use super::*;
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 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 async fn delete_master_key() -> Result<(), SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform".to_string(),
))
}
pub async fn has_master_key() -> bool {
false
}
}
// Re-export platform-specific functions
pub use platform::{delete_master_key, get_master_key, has_master_key, store_master_key};
/// Parse a hex string to bytes.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, SecretError> {
if !hex.len().is_multiple_of(2) {
return Err(SecretError::KeychainError(
"Invalid hex string length".to_string(),
));
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|_| SecretError::KeychainError("Invalid hex character".to_string()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_master_key() {
let key = generate_master_key();
assert_eq!(key.len(), 32);
// Should be different each time
let key2 = generate_master_key();
assert_ne!(key, key2);
}
#[test]
fn test_generate_master_key_hex() {
let hex = generate_master_key_hex();
assert_eq!(hex.len(), 64); // 32 bytes * 2 hex chars
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_hex_to_bytes() {
let result = hex_to_bytes("deadbeef").unwrap();
assert_eq!(result, vec![0xde, 0xad, 0xbe, 0xef]);
let result = hex_to_bytes("00ff").unwrap();
assert_eq!(result, vec![0x00, 0xff]);
}
#[test]
fn test_hex_to_bytes_invalid() {
assert!(hex_to_bytes("abc").is_err()); // Odd length
assert!(hex_to_bytes("gg").is_err()); // Invalid chars
}
}