gestura_core_security/
lib.rs

1//! Security primitives, secure storage, sandboxing, and privacy helpers.
2//!
3//! `gestura-core-security` owns the core security-related functionality for the
4//! workspace. It combines secret storage, optional encryption, execution
5//! sandboxing, and GDPR-focused data handling into a single domain crate.
6//!
7//! ## Responsibilities
8//!
9//! - secure storage abstraction with OS-keychain and mock implementations
10//! - AES-256-GCM encryption helpers behind the `security` feature
11//! - secret-provider integration for runtime config and provider credentials
12//! - sandbox configuration and isolation primitives
13//! - GDPR support such as export, deletion, consent, and audit-oriented helpers
14//!
15//! ## Security model
16//!
17//! The workspace follows a default-deny posture for dangerous behavior. This
18//! crate does not implement the full tool-permission system itself, but it
19//! provides the lower-level building blocks used by higher-level orchestration:
20//!
21//! - secure secret storage instead of plaintext where possible
22//! - explicit sandbox boundaries for untrusted execution
23//! - typed privacy and token models used across protocol and tool flows
24//!
25//! ## Feature-gated behavior
26//!
27//! - `security`: enables AES-256-GCM encryption and OS keychain integration
28//!
29//! When the `security` feature is unavailable or keychain access is disabled,
30//! the crate can fall back to mock/in-memory behavior that keeps tests and
31//! reduced environments usable without pretending secrets are durably protected.
32//!
33//! ## Stable import paths
34//!
35//! Most application code should import through the facade paths exposed by
36//! `gestura-core`, such as:
37//!
38//! - `gestura_core::security::*`
39//! - `gestura_core::gdpr::*`
40//! - `gestura_core::sandbox::*`
41
42pub mod gdpr;
43pub mod sandbox;
44pub mod secrets;
45pub mod storage;
46
47#[cfg(feature = "security")]
48pub mod encryption;
49
50// Re-exports for convenience
51pub use gdpr::*;
52pub use sandbox::*;
53pub use secrets::SecureStorageSecretProvider;
54pub use storage::{MockSecureStorage, SecureStorage, SecureStorageError};
55
56#[cfg(feature = "security")]
57pub use encryption::{Encryptor, SecureConfigManager};
58
59#[cfg(feature = "security")]
60pub use storage::KeychainStorage;
61
62/// Token for MCP authentication
63///
64/// Represents an authentication token with optional expiration and scope restrictions.
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
66pub struct McpToken {
67    /// The token string
68    pub token: String,
69    /// Optional expiration timestamp
70    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
71    /// Scopes this token is authorized for
72    pub scopes: Vec<String>,
73}
74
75impl McpToken {
76    /// Create a new MCP token
77    pub fn new(token: String) -> Self {
78        Self {
79            token,
80            expires_at: None,
81            scopes: Vec::new(),
82        }
83    }
84
85    /// Create a token with expiration
86    pub fn with_expiry(token: String, expires_at: chrono::DateTime<chrono::Utc>) -> Self {
87        Self {
88            token,
89            expires_at: Some(expires_at),
90            scopes: Vec::new(),
91        }
92    }
93
94    /// Add scopes to the token
95    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
96        self.scopes = scopes;
97        self
98    }
99
100    /// Check if the token is expired
101    pub fn is_expired(&self) -> bool {
102        self.expires_at
103            .map(|exp| exp < chrono::Utc::now())
104            .unwrap_or(false)
105    }
106
107    /// Check if token has a specific scope
108    pub fn has_scope(&self, scope: &str) -> bool {
109        self.scopes.iter().any(|s| s == scope)
110    }
111}
112
113/// Check if keychain access is disabled via environment variables
114///
115/// Returns `true` when `GESTURA_DISABLE_KEYCHAIN=1` or
116/// `GESTURA_NO_KEYCHAIN=1` is set.
117///
118/// Outside unit-test builds, `CI` also disables keychain access to avoid
119/// non-interactive runner hangs. Unit tests intentionally ignore bare `CI`
120/// because test builds already use in-memory mock secure storage.
121pub fn keychain_access_disabled() -> bool {
122    let explicitly_disabled = std::env::var_os("GESTURA_DISABLE_KEYCHAIN").is_some()
123        || std::env::var_os("GESTURA_NO_KEYCHAIN").is_some();
124
125    explicitly_disabled || (cfg!(not(test)) && std::env::var_os("CI").is_some())
126}
127
128/// Create the appropriate secure storage implementation based on features.
129///
130/// When the `security` feature is enabled, this returns a keychain-backed
131/// storage unless keychain access has been explicitly disabled. Otherwise, it
132/// returns an in-memory mock storage suitable for tests and constrained
133/// environments.
134pub fn create_secure_storage() -> Box<dyn SecureStorage> {
135    #[cfg(all(feature = "security", not(test)))]
136    {
137        if keychain_access_disabled() {
138            Box::new(MockSecureStorage::default())
139        } else {
140            Box::new(KeychainStorage)
141        }
142    }
143    #[cfg(any(not(feature = "security"), test))]
144    {
145        Box::new(MockSecureStorage::default())
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::ffi::OsString;
153    use std::sync::{Mutex, OnceLock};
154
155    fn env_lock() -> &'static Mutex<()> {
156        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
157        LOCK.get_or_init(|| Mutex::new(()))
158    }
159
160    struct ScopedEnvVar {
161        key: &'static str,
162        old: Option<OsString>,
163    }
164
165    impl ScopedEnvVar {
166        fn set(key: &'static str, value: &str) -> Self {
167            let old = std::env::var_os(key);
168            unsafe {
169                std::env::set_var(key, value);
170            }
171            Self { key, old }
172        }
173
174        fn unset(key: &'static str) -> Self {
175            let old = std::env::var_os(key);
176            unsafe {
177                std::env::remove_var(key);
178            }
179            Self { key, old }
180        }
181    }
182
183    impl Drop for ScopedEnvVar {
184        fn drop(&mut self) {
185            if let Some(value) = &self.old {
186                unsafe {
187                    std::env::set_var(self.key, value);
188                }
189            } else {
190                unsafe {
191                    std::env::remove_var(self.key);
192                }
193            }
194        }
195    }
196
197    #[test]
198    fn test_mcp_token_creation() {
199        let token = McpToken::new("test_token".to_string());
200        assert_eq!(token.token, "test_token");
201        assert!(token.expires_at.is_none());
202        assert!(token.scopes.is_empty());
203    }
204
205    #[test]
206    fn test_mcp_token_with_scopes() {
207        let token = McpToken::new("test".to_string())
208            .with_scopes(vec!["read".to_string(), "write".to_string()]);
209        assert!(token.has_scope("read"));
210        assert!(token.has_scope("write"));
211        assert!(!token.has_scope("admin"));
212    }
213
214    #[test]
215    fn test_mcp_token_expiry() {
216        let future = chrono::Utc::now() + chrono::Duration::hours(1);
217        let token = McpToken::with_expiry("test".to_string(), future);
218        assert!(!token.is_expired());
219
220        let past = chrono::Utc::now() - chrono::Duration::hours(1);
221        let expired_token = McpToken::with_expiry("test".to_string(), past);
222        assert!(expired_token.is_expired());
223    }
224
225    #[test]
226    fn keychain_access_disabled_ignores_ci_in_unit_tests() {
227        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
228        let _ci = ScopedEnvVar::set("CI", "1");
229        let _disabled = ScopedEnvVar::unset("GESTURA_DISABLE_KEYCHAIN");
230        let _no_keychain = ScopedEnvVar::unset("GESTURA_NO_KEYCHAIN");
231
232        assert!(
233            !keychain_access_disabled(),
234            "unit tests should not skip mocked secure storage just because CI is set"
235        );
236    }
237
238    #[test]
239    fn keychain_access_disabled_still_respects_explicit_overrides() {
240        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
241        let _ci = ScopedEnvVar::unset("CI");
242        let _disabled = ScopedEnvVar::set("GESTURA_DISABLE_KEYCHAIN", "1");
243        let _no_keychain = ScopedEnvVar::unset("GESTURA_NO_KEYCHAIN");
244
245        assert!(keychain_access_disabled());
246    }
247}