skill_runtime/
credentials.rs1use 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
11pub struct CredentialStore {
16 service_name: String,
17 audit_logger: Option<Arc<AuditLogger>>,
18}
19
20impl CredentialStore {
21 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 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 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 fn build_entry_key(&self, skill: &str, instance: &str, key: &str) -> String {
49 format!("{}/{}/{}", skill, instance, key)
50 }
51
52 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 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 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 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 Ok(Zeroizing::new(password))
112 }
113
114 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 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 pub fn delete_all_credentials(&self, skill: &str, instance: &str) -> Result<()> {
141 tracing::debug!(
144 skill = %skill,
145 instance = %instance,
146 "Deleting all credentials for instance"
147 );
148 Ok(())
149 }
150
151 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#[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); 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
212pub 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 }
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 }