Skip to main content

rustant_core/
secret_ref.rs

1//! Secret reference types for secure credential resolution.
2//!
3//! `SecretRef` provides a unified way to reference secrets that may be stored in:
4//! - OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
5//! - Environment variables
6//! - Inline plaintext (deprecated, with migration warnings)
7//!
8//! Format: `"keychain:<account>"`, `"env:<VAR>"`, or bare string (inline plaintext).
9
10use crate::credentials::{CredentialError, CredentialStore};
11use serde::{Deserialize, Serialize};
12
13/// A reference to a secret value that can be resolved at runtime.
14///
15/// Stored as a plain string with prefix-based dispatch:
16/// - `"keychain:<account>"` — resolve from OS keychain
17/// - `"env:<VAR_NAME>"` — resolve from environment variable
18/// - Any other string — treated as inline plaintext (deprecated)
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct SecretRef(String);
22
23impl SecretRef {
24    /// Create a new keychain-backed secret reference.
25    pub fn keychain(account: &str) -> Self {
26        Self(format!("keychain:{}", account))
27    }
28
29    /// Create a new environment variable-backed secret reference.
30    pub fn env(var_name: &str) -> Self {
31        Self(format!("env:{}", var_name))
32    }
33
34    /// Create an inline (plaintext) secret reference. This is deprecated.
35    pub fn inline(value: &str) -> Self {
36        Self(value.to_string())
37    }
38
39    /// Check if this reference is empty (no secret configured).
40    pub fn is_empty(&self) -> bool {
41        self.0.is_empty()
42    }
43
44    /// Check if this is a keychain reference.
45    pub fn is_keychain(&self) -> bool {
46        self.0.starts_with("keychain:")
47    }
48
49    /// Check if this is an environment variable reference.
50    pub fn is_env(&self) -> bool {
51        self.0.starts_with("env:")
52    }
53
54    /// Check if this is an inline (plaintext) value — deprecated usage.
55    pub fn is_inline(&self) -> bool {
56        !self.0.is_empty() && !self.is_keychain() && !self.is_env()
57    }
58
59    /// Get the raw reference string.
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63}
64
65impl From<String> for SecretRef {
66    fn from(s: String) -> Self {
67        Self(s)
68    }
69}
70
71impl From<&str> for SecretRef {
72    fn from(s: &str) -> Self {
73        Self(s.to_string())
74    }
75}
76
77/// Resolves `SecretRef` values to actual secret strings.
78pub struct SecretResolver;
79
80impl SecretResolver {
81    /// Resolve a `SecretRef` to its actual secret value.
82    ///
83    /// Resolution order by prefix:
84    /// 1. `"keychain:<account>"` — looks up in the OS credential store
85    /// 2. `"env:<VAR>"` — reads the environment variable
86    /// 3. Bare string — returns as-is with a deprecation warning
87    pub fn resolve(
88        secret_ref: &SecretRef,
89        store: &dyn CredentialStore,
90    ) -> Result<String, SecretResolveError> {
91        let raw = &secret_ref.0;
92        if raw.is_empty() {
93            return Err(SecretResolveError::Empty);
94        }
95
96        if let Some(account) = raw.strip_prefix("keychain:") {
97            store
98                .get_key(account)
99                .map_err(|e| SecretResolveError::KeychainError {
100                    account: account.to_string(),
101                    source: e,
102                })
103        } else if let Some(var) = raw.strip_prefix("env:") {
104            std::env::var(var).map_err(|_| SecretResolveError::EnvVarMissing {
105                var: var.to_string(),
106            })
107        } else {
108            // Inline plaintext — deprecated
109            tracing::warn!(
110                "Inline plaintext secret detected. Migrate to keychain with: rustant setup migrate-secrets"
111            );
112            Ok(raw.clone())
113        }
114    }
115}
116
117/// Errors from secret resolution.
118#[derive(Debug, thiserror::Error)]
119pub enum SecretResolveError {
120    #[error("Secret reference is empty")]
121    Empty,
122
123    #[error("Keychain lookup failed for '{account}': {source}")]
124    KeychainError {
125        account: String,
126        source: CredentialError,
127    },
128
129    #[error("Environment variable '{var}' not set")]
130    EnvVarMissing { var: String },
131}
132
133/// Result of migrating secrets from plaintext to keychain.
134#[derive(Debug, Default)]
135pub struct MigrationResult {
136    /// Number of secrets successfully migrated to keychain.
137    pub migrated: usize,
138    /// Number of secrets already using keychain or env refs.
139    pub already_secure: usize,
140    /// Errors encountered during migration.
141    pub errors: Vec<String>,
142}
143
144impl std::fmt::Display for MigrationResult {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(
147            f,
148            "Migration complete: {} migrated, {} already secure, {} errors",
149            self.migrated,
150            self.already_secure,
151            self.errors.len()
152        )
153    }
154}
155
156/// Migrate plaintext secrets in channel configs to keychain storage.
157///
158/// Scans all channel configs for non-empty plaintext token fields,
159/// stores each in the keychain under `"channel:{type}:{field}"`,
160/// and returns `SecretRef::keychain(...)` values to replace them.
161pub fn migrate_channel_secrets(
162    slack_token: Option<&str>,
163    discord_token: Option<&str>,
164    telegram_token: Option<&str>,
165    email_password: Option<&str>,
166    matrix_token: Option<&str>,
167    whatsapp_token: Option<&str>,
168    store: &dyn CredentialStore,
169) -> MigrationResult {
170    let mut result = MigrationResult::default();
171
172    let secrets = [
173        ("channel:slack:bot_token", slack_token),
174        ("channel:discord:bot_token", discord_token),
175        ("channel:telegram:bot_token", telegram_token),
176        ("channel:email:password", email_password),
177        ("channel:matrix:access_token", matrix_token),
178        ("channel:whatsapp:access_token", whatsapp_token),
179    ];
180
181    for (account, value) in &secrets {
182        match value {
183            Some(v) if !v.is_empty() && !v.starts_with("keychain:") && !v.starts_with("env:") => {
184                match store.store_key(account, v) {
185                    Ok(()) => {
186                        tracing::info!(account = account, "Migrated secret to keychain");
187                        result.migrated += 1;
188                    }
189                    Err(e) => {
190                        result
191                            .errors
192                            .push(format!("Failed to store {}: {}", account, e));
193                    }
194                }
195            }
196            Some(v) if v.starts_with("keychain:") || v.starts_with("env:") => {
197                result.already_secure += 1;
198            }
199            _ => {
200                // Empty or None — nothing to migrate
201            }
202        }
203    }
204
205    result
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::credentials::InMemoryCredentialStore;
212
213    #[test]
214    fn test_secret_ref_keychain() {
215        let sr = SecretRef::keychain("channel:slack:bot_token");
216        assert!(sr.is_keychain());
217        assert!(!sr.is_env());
218        assert!(!sr.is_inline());
219        assert!(!sr.is_empty());
220        assert_eq!(sr.as_str(), "keychain:channel:slack:bot_token");
221    }
222
223    #[test]
224    fn test_secret_ref_env() {
225        let sr = SecretRef::env("SLACK_BOT_TOKEN");
226        assert!(sr.is_env());
227        assert!(!sr.is_keychain());
228        assert!(!sr.is_inline());
229        assert_eq!(sr.as_str(), "env:SLACK_BOT_TOKEN");
230    }
231
232    #[test]
233    fn test_secret_ref_inline() {
234        let sr = SecretRef::inline("xoxb-123-456");
235        assert!(sr.is_inline());
236        assert!(!sr.is_keychain());
237        assert!(!sr.is_env());
238    }
239
240    #[test]
241    fn test_secret_ref_empty() {
242        let sr = SecretRef::default();
243        assert!(sr.is_empty());
244        assert!(!sr.is_inline());
245    }
246
247    #[test]
248    fn test_resolve_keychain() {
249        let store = InMemoryCredentialStore::new();
250        store
251            .store_key("channel:slack:bot_token", "xoxb-secret")
252            .unwrap();
253        let sr = SecretRef::keychain("channel:slack:bot_token");
254        let resolved = SecretResolver::resolve(&sr, &store).unwrap();
255        assert_eq!(resolved, "xoxb-secret");
256    }
257
258    #[test]
259    fn test_resolve_env() {
260        std::env::set_var("RUSTANT_TEST_SECRET_REF", "env-secret-value");
261        let sr = SecretRef::env("RUSTANT_TEST_SECRET_REF");
262        let store = InMemoryCredentialStore::new();
263        let resolved = SecretResolver::resolve(&sr, &store).unwrap();
264        assert_eq!(resolved, "env-secret-value");
265        std::env::remove_var("RUSTANT_TEST_SECRET_REF");
266    }
267
268    #[test]
269    fn test_resolve_inline() {
270        let sr = SecretRef::inline("plaintext-token");
271        let store = InMemoryCredentialStore::new();
272        let resolved = SecretResolver::resolve(&sr, &store).unwrap();
273        assert_eq!(resolved, "plaintext-token");
274    }
275
276    #[test]
277    fn test_resolve_empty_errors() {
278        let sr = SecretRef::default();
279        let store = InMemoryCredentialStore::new();
280        assert!(SecretResolver::resolve(&sr, &store).is_err());
281    }
282
283    #[test]
284    fn test_resolve_keychain_not_found() {
285        let sr = SecretRef::keychain("nonexistent");
286        let store = InMemoryCredentialStore::new();
287        let result = SecretResolver::resolve(&sr, &store);
288        assert!(result.is_err());
289        assert!(matches!(
290            result.unwrap_err(),
291            SecretResolveError::KeychainError { .. }
292        ));
293    }
294
295    #[test]
296    fn test_resolve_env_missing() {
297        std::env::remove_var("RUSTANT_ABSOLUTELY_MISSING_VAR");
298        let sr = SecretRef::env("RUSTANT_ABSOLUTELY_MISSING_VAR");
299        let store = InMemoryCredentialStore::new();
300        let result = SecretResolver::resolve(&sr, &store);
301        assert!(result.is_err());
302        assert!(matches!(
303            result.unwrap_err(),
304            SecretResolveError::EnvVarMissing { .. }
305        ));
306    }
307
308    #[test]
309    fn test_serde_transparent() {
310        let sr = SecretRef::keychain("test:account");
311        let json = serde_json::to_string(&sr).unwrap();
312        assert_eq!(json, "\"keychain:test:account\"");
313        let parsed: SecretRef = serde_json::from_str(&json).unwrap();
314        assert_eq!(parsed.as_str(), "keychain:test:account");
315    }
316
317    #[test]
318    fn test_migrate_channel_secrets() {
319        let store = InMemoryCredentialStore::new();
320        let result = migrate_channel_secrets(
321            Some("xoxb-plaintext-token"),
322            None,
323            Some("env:TELEGRAM_TOKEN"), // already secure
324            Some("my-email-password"),
325            None,
326            None,
327            &store,
328        );
329        assert_eq!(result.migrated, 2); // slack + email
330        assert_eq!(result.already_secure, 1); // telegram
331        assert!(result.errors.is_empty());
332
333        // Verify stored in keychain
334        assert_eq!(
335            store.get_key("channel:slack:bot_token").unwrap(),
336            "xoxb-plaintext-token"
337        );
338        assert_eq!(
339            store.get_key("channel:email:password").unwrap(),
340            "my-email-password"
341        );
342    }
343}