gestura_core_security/
secrets.rs

1//! Secret (API key) retrieval backed by secure storage.
2//!
3//! Pure abstractions (`SecretKey`, `SecretProvider`, `NullSecretProvider`) live in
4//! `gestura-core-foundation::secrets` and are re-exported from the crate root.
5//!
6//! This module adds the `SecureStorageSecretProvider` implementation that depends
7//! on [`SecureStorage`] (OS keychain).
8
9use crate::SecureStorage;
10use gestura_core_foundation::secrets::{SecretKey, SecretProvider};
11
12/// A `SecretProvider` backed by `SecureStorage`.
13///
14/// This is the canonical implementation for GUI/desktop usage where keychain
15/// storage is available behind the `security` feature.
16pub struct SecureStorageSecretProvider {
17    storage: Box<dyn SecureStorage>,
18}
19
20impl std::fmt::Debug for SecureStorageSecretProvider {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.debug_struct("SecureStorageSecretProvider")
23            .finish_non_exhaustive()
24    }
25}
26
27impl SecureStorageSecretProvider {
28    /// Create a new provider backed by the given secure storage.
29    pub fn new(storage: Box<dyn SecureStorage>) -> Self {
30        Self { storage }
31    }
32}
33
34#[async_trait::async_trait]
35impl SecretProvider for SecureStorageSecretProvider {
36    async fn get_secret(&self, key: SecretKey) -> Option<String> {
37        let canonical_key = key.storage_key();
38
39        // 1) Canonical key
40        match self.storage.get_secret(canonical_key).await {
41            Ok(Some(v)) if !v.is_empty() => return Some(v),
42            Ok(_) => {}
43            Err(e) => {
44                tracing::warn!(
45                    storage_key = canonical_key,
46                    error = %e,
47                    "Failed to read secret from secure storage"
48                );
49            }
50        }
51
52        // 2) Legacy key fallback + self-heal
53        let legacy_key = key.legacy_storage_key()?;
54        match self.storage.get_secret(legacy_key).await {
55            Ok(Some(v)) if !v.is_empty() => {
56                if let Err(e) = self.storage.store_secret(canonical_key, &v).await {
57                    tracing::warn!(
58                        canonical_key,
59                        legacy_key,
60                        error = %e,
61                        "Failed to self-heal secret from legacy key to canonical key"
62                    );
63                }
64                Some(v)
65            }
66            Ok(_) => None,
67            Err(e) => {
68                tracing::warn!(
69                    storage_key = legacy_key,
70                    error = %e,
71                    "Failed to read secret from secure storage (legacy key)"
72                );
73                None
74            }
75        }
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::{MockSecureStorage, SecureStorageError};
83    use std::sync::Arc;
84
85    /// Wrapper so we can both:
86    /// - give `SecureStorageSecretProvider` an owned `Box<dyn SecureStorage>`
87    /// - keep a handle to the underlying mock storage for assertions
88    #[derive(Clone)]
89    struct SharedMockStorage(Arc<MockSecureStorage>);
90
91    #[async_trait::async_trait]
92    impl SecureStorage for SharedMockStorage {
93        async fn store_secret(&self, key: &str, value: &str) -> Result<(), SecureStorageError> {
94            self.0.store_secret(key, value).await
95        }
96
97        async fn get_secret(&self, key: &str) -> Result<Option<String>, SecureStorageError> {
98            self.0.get_secret(key).await
99        }
100
101        async fn delete_secret(&self, key: &str) -> Result<(), SecureStorageError> {
102            self.0.delete_secret(key).await
103        }
104    }
105
106    #[tokio::test]
107    async fn reads_legacy_key_and_self_heals_to_canonical() {
108        let inner = Arc::new(MockSecureStorage::new());
109        inner
110            .store_secret("gestura_api_key_openai", "sk-legacy")
111            .await
112            .unwrap();
113
114        let provider = SecureStorageSecretProvider::new(Box::new(SharedMockStorage(inner.clone())));
115
116        let got = provider.get_secret(SecretKey::OpenAi).await;
117        assert_eq!(got.as_deref(), Some("sk-legacy"));
118
119        let canonical = inner
120            .get_secret("gestura_llm_openai_api_key")
121            .await
122            .unwrap();
123        assert_eq!(canonical.as_deref(), Some("sk-legacy"));
124    }
125
126    #[tokio::test]
127    async fn canonical_key_wins_over_legacy_key() {
128        let inner = Arc::new(MockSecureStorage::new());
129        inner
130            .store_secret("gestura_llm_openai_api_key", "sk-canonical")
131            .await
132            .unwrap();
133        inner
134            .store_secret("gestura_api_key_openai", "sk-legacy")
135            .await
136            .unwrap();
137
138        let provider = SecureStorageSecretProvider::new(Box::new(SharedMockStorage(inner.clone())));
139
140        let got = provider.get_secret(SecretKey::OpenAi).await;
141        assert_eq!(got.as_deref(), Some("sk-canonical"));
142    }
143}