skill_runtime/
credentials.rs

1use anyhow::{Context, Result};
2use keyring::Entry;
3use std::fmt;
4use std::sync::Arc;
5use zeroize::{Zeroize, Zeroizing};
6
7use crate::audit::AuditLogger;
8
9const SERVICE_NAME: &str = "skill-engine";
10
11/// Secure credential storage using platform-specific keychains
12/// - macOS: Keychain
13/// - Windows: Credential Manager
14/// - Linux: Secret Service (DBus)
15pub struct CredentialStore {
16    service_name: String,
17    audit_logger: Option<Arc<AuditLogger>>,
18}
19
20impl CredentialStore {
21    /// Create a new credential store
22    pub fn new() -> Self {
23        let audit_logger = AuditLogger::new().ok().map(Arc::new);
24        Self {
25            service_name: SERVICE_NAME.to_string(),
26            audit_logger,
27        }
28    }
29
30    /// Create a new credential store with custom service name
31    pub fn with_service_name(service_name: String) -> Self {
32        let audit_logger = AuditLogger::new().ok().map(Arc::new);
33        Self {
34            service_name,
35            audit_logger,
36        }
37    }
38
39    /// Create a credential store with audit logging
40    pub fn with_audit_logger(audit_logger: Arc<AuditLogger>) -> Self {
41        Self {
42            service_name: SERVICE_NAME.to_string(),
43            audit_logger: Some(audit_logger),
44        }
45    }
46
47    /// Build keyring entry key: "skill-engine/{skill_name}/{instance_name}/{key_name}"
48    fn build_entry_key(&self, skill: &str, instance: &str, key: &str) -> String {
49        format!("{}/{}/{}", skill, instance, key)
50    }
51
52    /// Store a credential securely
53    pub fn store_credential(
54        &self,
55        skill: &str,
56        instance: &str,
57        key: &str,
58        value: &str,
59    ) -> Result<()> {
60        let entry_key = self.build_entry_key(skill, instance, key);
61        let entry = Entry::new(&self.service_name, &entry_key)
62            .context("Failed to create keyring entry")?;
63
64        entry
65            .set_password(value)
66            .with_context(|| format!("Failed to store credential for key: {}", key))?;
67
68        // Audit log
69        if let Some(ref logger) = self.audit_logger {
70            let _ = logger.log_credential_store(skill, instance, key);
71        }
72
73        tracing::debug!(
74            skill = %skill,
75            instance = %instance,
76            key = %key,
77            "Stored credential in keyring"
78        );
79
80        Ok(())
81    }
82
83    /// Retrieve a credential securely (returns a zeroizing string that clears on drop)
84    pub fn get_credential(
85        &self,
86        skill: &str,
87        instance: &str,
88        key: &str,
89    ) -> Result<Zeroizing<String>> {
90        let entry_key = self.build_entry_key(skill, instance, key);
91        let entry = Entry::new(&self.service_name, &entry_key)
92            .context("Failed to create keyring entry")?;
93
94        let password = entry
95            .get_password()
96            .with_context(|| format!("Failed to retrieve credential for key: {}", key))?;
97
98        // Audit log
99        if let Some(ref logger) = self.audit_logger {
100            let _ = logger.log_credential_access(skill, instance, key);
101        }
102
103        tracing::debug!(
104            skill = %skill,
105            instance = %instance,
106            key = %key,
107            "Retrieved credential from keyring"
108        );
109
110        // Wrap in Zeroizing to clear memory on drop
111        Ok(Zeroizing::new(password))
112    }
113
114    /// Delete a credential
115    pub fn delete_credential(&self, skill: &str, instance: &str, key: &str) -> Result<()> {
116        let entry_key = self.build_entry_key(skill, instance, key);
117        let entry = Entry::new(&self.service_name, &entry_key)
118            .context("Failed to create keyring entry")?;
119
120        entry
121            .delete_credential()
122            .with_context(|| format!("Failed to delete credential for key: {}", key))?;
123
124        // Audit log
125        if let Some(ref logger) = self.audit_logger {
126            let _ = logger.log_credential_delete(skill, instance, key);
127        }
128
129        tracing::debug!(
130            skill = %skill,
131            instance = %instance,
132            key = %key,
133            "Deleted credential from keyring"
134        );
135
136        Ok(())
137    }
138
139    /// Delete all credentials for an instance
140    pub fn delete_all_credentials(&self, skill: &str, instance: &str) -> Result<()> {
141        // Note: keyring doesn't provide a list operation, so callers must
142        // track which keys they stored and call delete_credential for each
143        tracing::debug!(
144            skill = %skill,
145            instance = %instance,
146            "Deleting all credentials for instance"
147        );
148        Ok(())
149    }
150
151    /// Check if a credential exists
152    pub fn has_credential(&self, skill: &str, instance: &str, key: &str) -> bool {
153        let entry_key = self.build_entry_key(skill, instance, key);
154        if let Ok(entry) = Entry::new(&self.service_name, &entry_key) {
155            entry.get_password().is_ok()
156        } else {
157            false
158        }
159    }
160}
161
162impl Default for CredentialStore {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168/// Secure string that zeroes memory on drop
169#[derive(Clone)]
170pub struct SecureString(String);
171
172impl SecureString {
173    pub fn new(s: String) -> Self {
174        Self(s)
175    }
176
177    pub fn as_str(&self) -> &str {
178        &self.0
179    }
180
181    pub fn into_string(mut self) -> String {
182        let s = std::mem::take(&mut self.0);
183        std::mem::forget(self); // Prevent double-zeroing
184        s
185    }
186}
187
188impl From<String> for SecureString {
189    fn from(s: String) -> Self {
190        Self::new(s)
191    }
192}
193
194impl From<&str> for SecureString {
195    fn from(s: &str) -> Self {
196        Self::new(s.to_string())
197    }
198}
199
200impl fmt::Debug for SecureString {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        f.write_str("SecureString([REDACTED])")
203    }
204}
205
206impl Drop for SecureString {
207    fn drop(&mut self) {
208        self.0.zeroize();
209    }
210}
211
212/// Parse a keyring reference URL: "keyring://skill-engine/{skill}/{instance}/{key}"
213pub fn parse_keyring_reference(reference: &str) -> Result<(String, String, String)> {
214    let prefix = "keyring://skill-engine/";
215    if !reference.starts_with(prefix) {
216        anyhow::bail!("Invalid keyring reference: must start with '{}'", prefix);
217    }
218
219    let path = &reference[prefix.len()..];
220    let parts: Vec<&str> = path.split('/').collect();
221
222    if parts.len() != 3 {
223        anyhow::bail!(
224            "Invalid keyring reference format: expected 'keyring://skill-engine/{{skill}}/{{instance}}/{{key}}'"
225        );
226    }
227
228    Ok((
229        parts[0].to_string(),
230        parts[1].to_string(),
231        parts[2].to_string(),
232    ))
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_parse_keyring_reference() {
241        let reference = "keyring://skill-engine/aws-skill/prod/aws_access_key_id";
242        let (skill, instance, key) = parse_keyring_reference(reference).unwrap();
243
244        assert_eq!(skill, "aws-skill");
245        assert_eq!(instance, "prod");
246        assert_eq!(key, "aws_access_key_id");
247    }
248
249    #[test]
250    fn test_parse_keyring_reference_invalid() {
251        let reference = "invalid://aws-skill/prod/key";
252        assert!(parse_keyring_reference(reference).is_err());
253
254        let reference = "keyring://skill-engine/only-two/parts";
255        assert!(parse_keyring_reference(reference).is_err());
256    }
257
258    #[test]
259    fn test_secure_string_zeroes_memory() {
260        let secret = SecureString::new("sensitive".to_string());
261        assert_eq!(secret.as_str(), "sensitive");
262
263        drop(secret);
264        // Memory should be zeroed after drop (can't easily test this without unsafe)
265    }
266
267    #[test]
268    fn test_secure_string_debug() {
269        let secret = SecureString::new("sensitive".to_string());
270        let debug_str = format!("{:?}", secret);
271        assert_eq!(debug_str, "SecureString([REDACTED])");
272        assert!(!debug_str.contains("sensitive"));
273    }
274
275    // Note: Actual keyring operations are not tested here as they require
276    // platform-specific keyring services. Use integration tests with mocks.
277}