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        // SAFETY: test-only env var manipulation
261        unsafe { std::env::set_var("RUSTANT_TEST_SECRET_REF", "env-secret-value") };
262        let sr = SecretRef::env("RUSTANT_TEST_SECRET_REF");
263        let store = InMemoryCredentialStore::new();
264        let resolved = SecretResolver::resolve(&sr, &store).unwrap();
265        assert_eq!(resolved, "env-secret-value");
266        // SAFETY: test-only env var manipulation
267        unsafe { std::env::remove_var("RUSTANT_TEST_SECRET_REF") };
268    }
269
270    #[test]
271    fn test_resolve_inline() {
272        let sr = SecretRef::inline("plaintext-token");
273        let store = InMemoryCredentialStore::new();
274        let resolved = SecretResolver::resolve(&sr, &store).unwrap();
275        assert_eq!(resolved, "plaintext-token");
276    }
277
278    #[test]
279    fn test_resolve_empty_errors() {
280        let sr = SecretRef::default();
281        let store = InMemoryCredentialStore::new();
282        assert!(SecretResolver::resolve(&sr, &store).is_err());
283    }
284
285    #[test]
286    fn test_resolve_keychain_not_found() {
287        let sr = SecretRef::keychain("nonexistent");
288        let store = InMemoryCredentialStore::new();
289        let result = SecretResolver::resolve(&sr, &store);
290        assert!(result.is_err());
291        assert!(matches!(
292            result.unwrap_err(),
293            SecretResolveError::KeychainError { .. }
294        ));
295    }
296
297    #[test]
298    fn test_resolve_env_missing() {
299        // SAFETY: test-only env var manipulation
300        unsafe { std::env::remove_var("RUSTANT_ABSOLUTELY_MISSING_VAR") };
301        let sr = SecretRef::env("RUSTANT_ABSOLUTELY_MISSING_VAR");
302        let store = InMemoryCredentialStore::new();
303        let result = SecretResolver::resolve(&sr, &store);
304        assert!(result.is_err());
305        assert!(matches!(
306            result.unwrap_err(),
307            SecretResolveError::EnvVarMissing { .. }
308        ));
309    }
310
311    #[test]
312    fn test_serde_transparent() {
313        let sr = SecretRef::keychain("test:account");
314        let json = serde_json::to_string(&sr).unwrap();
315        assert_eq!(json, "\"keychain:test:account\"");
316        let parsed: SecretRef = serde_json::from_str(&json).unwrap();
317        assert_eq!(parsed.as_str(), "keychain:test:account");
318    }
319
320    #[test]
321    fn test_migrate_channel_secrets() {
322        let store = InMemoryCredentialStore::new();
323        let result = migrate_channel_secrets(
324            Some("xoxb-plaintext-token"),
325            None,
326            Some("env:TELEGRAM_TOKEN"), // already secure
327            Some("my-email-password"),
328            None,
329            None,
330            &store,
331        );
332        assert_eq!(result.migrated, 2); // slack + email
333        assert_eq!(result.already_secure, 1); // telegram
334        assert!(result.errors.is_empty());
335
336        // Verify stored in keychain
337        assert_eq!(
338            store.get_key("channel:slack:bot_token").unwrap(),
339            "xoxb-plaintext-token"
340        );
341        assert_eq!(
342            store.get_key("channel:email:password").unwrap(),
343            "my-email-password"
344        );
345    }
346}