Skip to main content

quantrs2_device/security/
credentials.rs

1//! Credential management for quantum cloud providers.
2//!
3//! Abstracts credential storage behind a trait so the same code works
4//! with environment variables, files, or future vault integrations.
5
6use std::collections::HashMap;
7
8/// A secret string that zeroes its memory on drop to reduce secret leakage.
9///
10/// Does not implement `Clone` to prevent accidental copies. Access the
11/// underlying value with [`SecretString::expose_secret`] in a short-lived context.
12pub struct SecretString {
13    inner: Vec<u8>,
14}
15
16impl SecretString {
17    /// Wrap a string value as a secret
18    pub fn new(s: impl Into<String>) -> Self {
19        Self {
20            inner: s.into().into_bytes(),
21        }
22    }
23
24    /// Access the secret value as a string slice.
25    ///
26    /// Keep the borrow as short-lived as possible to limit exposure.
27    pub fn expose_secret(&self) -> &str {
28        // SAFETY: We stored valid UTF-8 from Into<String>.
29        std::str::from_utf8(&self.inner).unwrap_or("")
30    }
31
32    /// Return the length in bytes
33    pub fn len(&self) -> usize {
34        self.inner.len()
35    }
36
37    /// Return true if the secret is empty
38    pub fn is_empty(&self) -> bool {
39        self.inner.is_empty()
40    }
41}
42
43impl Drop for SecretString {
44    fn drop(&mut self) {
45        // Volatile write prevents the compiler from optimizing away the zeroing.
46        for b in self.inner.iter_mut() {
47            // SAFETY: `b` is a valid mutable reference to a byte within our Vec.
48            unsafe {
49                std::ptr::write_volatile(b, 0u8);
50            }
51        }
52    }
53}
54
55impl std::fmt::Debug for SecretString {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.write_str("[REDACTED]")
58    }
59}
60
61impl std::fmt::Display for SecretString {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.write_str("[REDACTED]")
64    }
65}
66
67/// Error type for credential operations
68#[derive(Debug)]
69#[non_exhaustive]
70pub enum CredentialError {
71    /// The requested credential key was not found
72    NotFound(String),
73    /// File permissions are too permissive
74    PermissionDenied(String),
75    /// Underlying I/O failure
76    IoError(std::io::Error),
77    /// Failed to parse credential file
78    ParseError(String),
79}
80
81impl std::fmt::Display for CredentialError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            CredentialError::NotFound(k) => write!(f, "credential not found: {}", k),
85            CredentialError::PermissionDenied(p) => write!(f, "permission denied: {}", p),
86            CredentialError::IoError(e) => write!(f, "credential IO error: {}", e),
87            CredentialError::ParseError(s) => write!(f, "credential parse error: {}", s),
88        }
89    }
90}
91
92impl std::error::Error for CredentialError {
93    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
94        match self {
95            CredentialError::IoError(e) => Some(e),
96            _ => None,
97        }
98    }
99}
100
101impl From<std::io::Error> for CredentialError {
102    fn from(e: std::io::Error) -> Self {
103        CredentialError::IoError(e)
104    }
105}
106
107/// Abstraction for credential storage backends
108pub trait CredentialProvider: Send + Sync {
109    /// Retrieve a credential by key, returning a zeroing `SecretString`
110    fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError>;
111
112    /// List available credential keys.
113    ///
114    /// May return an empty vec for security reasons (e.g. env-var providers
115    /// avoid enumerating the process environment).
116    fn available_keys(&self) -> Vec<String>;
117}
118
119/// Reads credentials from environment variables.
120///
121/// Keys are looked up as `{PREFIX}_{KEY}` (both uppercased). If the prefix
122/// is empty the key is used directly, uppercased.
123///
124/// # Example
125///
126/// ```rust,no_run
127/// # use quantrs2_device::security::credentials::{EnvVarCredentialProvider, CredentialProvider};
128/// let provider = EnvVarCredentialProvider::new("QUANTRS");
129/// // looks for env var "QUANTRS_IBM_TOKEN"
130/// let _ = provider.get_credential("IBM_TOKEN");
131/// ```
132pub struct EnvVarCredentialProvider {
133    prefix: String,
134}
135
136impl EnvVarCredentialProvider {
137    /// Create a new provider with the given environment variable prefix
138    pub fn new(prefix: impl Into<String>) -> Self {
139        Self {
140            prefix: prefix.into().to_uppercase(),
141        }
142    }
143
144    fn env_key(&self, key: &str) -> String {
145        if self.prefix.is_empty() {
146            key.to_uppercase()
147        } else {
148            format!("{}_{}", self.prefix, key.to_uppercase())
149        }
150    }
151}
152
153impl CredentialProvider for EnvVarCredentialProvider {
154    fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
155        let env_key = self.env_key(key);
156        std::env::var(&env_key)
157            .map(SecretString::new)
158            .map_err(|_| CredentialError::NotFound(env_key))
159    }
160
161    fn available_keys(&self) -> Vec<String> {
162        // Don't enumerate environment variables — it's a security risk
163        vec![]
164    }
165}
166
167/// Reads credentials from a JSON file (e.g. `~/.quantrs/credentials.json`).
168///
169/// The file must contain a flat JSON object mapping string keys to string values.
170/// On Unix systems the file must have mode `0o600`; looser permissions are rejected.
171///
172/// # Example file
173///
174/// ```json
175/// {
176///   "IBM_TOKEN": "my-ibm-token",
177///   "AWS_SECRET": "my-aws-secret"
178/// }
179/// ```
180pub struct FileCredentialProvider {
181    path: std::path::PathBuf,
182}
183
184impl FileCredentialProvider {
185    /// Create a provider that reads from the given JSON credentials file
186    pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
187        Self { path: path.into() }
188    }
189
190    fn load(&self) -> Result<HashMap<String, String>, CredentialError> {
191        #[cfg(unix)]
192        {
193            use std::os::unix::fs::MetadataExt;
194            let meta = std::fs::metadata(&self.path).map_err(CredentialError::IoError)?;
195            if meta.mode() & 0o077 != 0 {
196                return Err(CredentialError::PermissionDenied(format!(
197                    "credentials file {:?} must have mode 0600",
198                    self.path
199                )));
200            }
201        }
202
203        let content = std::fs::read_to_string(&self.path).map_err(CredentialError::IoError)?;
204        serde_json::from_str::<HashMap<String, String>>(&content)
205            .map_err(|e| CredentialError::ParseError(e.to_string()))
206    }
207}
208
209impl CredentialProvider for FileCredentialProvider {
210    fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
211        let map = self.load()?;
212        map.get(key)
213            .map(|v| SecretString::new(v.clone()))
214            .ok_or_else(|| CredentialError::NotFound(key.to_string()))
215    }
216
217    fn available_keys(&self) -> Vec<String> {
218        self.load()
219            .map(|m| m.into_keys().collect())
220            .unwrap_or_default()
221    }
222}
223
224/// Tries multiple [`CredentialProvider`]s in order until one succeeds.
225///
226/// Useful for layering: environment variables override file credentials, which
227/// override compiled-in defaults.
228///
229/// # Example
230///
231/// ```rust,no_run
232/// # use quantrs2_device::security::credentials::{
233/// #     CompositeCredentialProvider, EnvVarCredentialProvider, CredentialProvider
234/// # };
235/// let provider = CompositeCredentialProvider::new()
236///     .with_provider(EnvVarCredentialProvider::new("QUANTRS"));
237/// let _ = provider.get_credential("IBM_TOKEN");
238/// ```
239pub struct CompositeCredentialProvider {
240    providers: Vec<Box<dyn CredentialProvider>>,
241}
242
243impl CompositeCredentialProvider {
244    /// Create an empty composite provider (no sources yet)
245    pub fn new() -> Self {
246        Self { providers: vec![] }
247    }
248
249    /// Append a credential provider source (tried in insertion order)
250    pub fn with_provider(mut self, p: impl CredentialProvider + 'static) -> Self {
251        self.providers.push(Box::new(p));
252        self
253    }
254}
255
256impl Default for CompositeCredentialProvider {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262impl CredentialProvider for CompositeCredentialProvider {
263    fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
264        for p in &self.providers {
265            if let Ok(s) = p.get_credential(key) {
266                return Ok(s);
267            }
268        }
269        Err(CredentialError::NotFound(key.to_string()))
270    }
271
272    fn available_keys(&self) -> Vec<String> {
273        self.providers
274            .iter()
275            .flat_map(|p| p.available_keys())
276            .collect()
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::env;
284    use std::io::Write;
285
286    #[test]
287    fn test_secret_string_expose() {
288        let secret = SecretString::new("my-api-key");
289        assert_eq!(secret.expose_secret(), "my-api-key");
290    }
291
292    #[test]
293    fn test_secret_string_debug_redacted() {
294        let secret = SecretString::new("super-secret");
295        assert_eq!(format!("{:?}", secret), "[REDACTED]");
296        assert_eq!(format!("{}", secret), "[REDACTED]");
297    }
298
299    #[test]
300    fn test_secret_string_len() {
301        let secret = SecretString::new("hello");
302        assert_eq!(secret.len(), 5);
303        assert!(!secret.is_empty());
304    }
305
306    #[test]
307    fn test_secret_string_empty() {
308        let secret = SecretString::new("");
309        assert!(secret.is_empty());
310        assert_eq!(secret.len(), 0);
311    }
312
313    #[test]
314    fn test_env_var_provider_found() {
315        let key = format!("QUANTRS_TEST_KEY_{}", fastrand::u64(..));
316        env::set_var(&key, "test-token");
317
318        let provider = EnvVarCredentialProvider::new("");
319        let secret = provider.get_credential(&key).expect("should find env var");
320        assert_eq!(secret.expose_secret(), "test-token");
321
322        env::remove_var(&key);
323    }
324
325    #[test]
326    fn test_env_var_provider_with_prefix() {
327        let suffix = fastrand::u64(..);
328        let env_var = format!("QUANTRS_TEST_{}", suffix);
329        env::set_var(&env_var, "prefixed-value");
330
331        let provider = EnvVarCredentialProvider::new("QUANTRS");
332        let secret = provider
333            .get_credential(&format!("TEST_{}", suffix))
334            .expect("should find prefixed env var");
335        assert_eq!(secret.expose_secret(), "prefixed-value");
336
337        env::remove_var(&env_var);
338    }
339
340    #[test]
341    fn test_env_var_provider_not_found() {
342        let provider = EnvVarCredentialProvider::new("QUANTRS");
343        let result = provider.get_credential("DEFINITELY_NONEXISTENT_KEY_12345");
344        assert!(matches!(result, Err(CredentialError::NotFound(_))));
345    }
346
347    #[test]
348    fn test_env_var_provider_available_keys_empty() {
349        let provider = EnvVarCredentialProvider::new("QUANTRS");
350        assert!(provider.available_keys().is_empty());
351    }
352
353    #[test]
354    fn test_file_credential_provider() {
355        let dir = env::temp_dir();
356        let path = dir.join(format!("quantrs_creds_{}.json", fastrand::u64(..)));
357
358        let mut file = std::fs::File::create(&path).expect("create file");
359        write!(
360            file,
361            r#"{{"IBM_TOKEN":"ibm-secret","AWS_KEY":"aws-secret"}}"#
362        )
363        .expect("write credentials");
364        drop(file);
365
366        // Set mode 0600 on Unix so the permission check passes
367        #[cfg(unix)]
368        {
369            use std::os::unix::fs::PermissionsExt;
370            let perms = std::fs::Permissions::from_mode(0o600);
371            std::fs::set_permissions(&path, perms).expect("set permissions");
372        }
373
374        let provider = FileCredentialProvider::new(&path);
375        let secret = provider
376            .get_credential("IBM_TOKEN")
377            .expect("should find IBM_TOKEN");
378        assert_eq!(secret.expose_secret(), "ibm-secret");
379
380        let keys = provider.available_keys();
381        assert!(keys.contains(&"IBM_TOKEN".to_string()) || keys.contains(&"AWS_KEY".to_string()));
382
383        let _ = std::fs::remove_file(&path);
384    }
385
386    #[test]
387    fn test_file_credential_provider_not_found() {
388        let dir = env::temp_dir();
389        let path = dir.join(format!("quantrs_creds_nf_{}.json", fastrand::u64(..)));
390
391        let mut file = std::fs::File::create(&path).expect("create file");
392        write!(file, r#"{{"IBM_TOKEN":"ibm-secret"}}"#).expect("write credentials");
393        drop(file);
394
395        #[cfg(unix)]
396        {
397            use std::os::unix::fs::PermissionsExt;
398            let perms = std::fs::Permissions::from_mode(0o600);
399            std::fs::set_permissions(&path, perms).expect("set permissions");
400        }
401
402        let provider = FileCredentialProvider::new(&path);
403        let result = provider.get_credential("NONEXISTENT");
404        assert!(matches!(result, Err(CredentialError::NotFound(_))));
405
406        let _ = std::fs::remove_file(&path);
407    }
408
409    #[test]
410    fn test_composite_provider_fallthrough() {
411        // First provider has nothing; second has the key
412        let suffix = fastrand::u64(..);
413        let env_var = format!("QUANTRS_COMPOSITE_{}", suffix);
414        env::set_var(&env_var, "composite-value");
415
416        // Env provider with wrong prefix → won't find it
417        let missing_provider = EnvVarCredentialProvider::new("WRONG_PREFIX");
418        // Env provider with no prefix → will find it as-is
419        let found_provider = EnvVarCredentialProvider::new("");
420
421        let composite = CompositeCredentialProvider::new()
422            .with_provider(missing_provider)
423            .with_provider(found_provider);
424
425        let secret = composite
426            .get_credential(&env_var)
427            .expect("composite should find via second provider");
428        assert_eq!(secret.expose_secret(), "composite-value");
429
430        env::remove_var(&env_var);
431    }
432
433    #[test]
434    fn test_composite_provider_all_fail() {
435        let composite = CompositeCredentialProvider::new().with_provider(
436            EnvVarCredentialProvider::new("DEFINITELY_MISSING_PREFIX_XYZ"),
437        );
438
439        let result = composite.get_credential("NONEXISTENT");
440        assert!(matches!(result, Err(CredentialError::NotFound(_))));
441    }
442
443    #[test]
444    fn test_composite_provider_empty() {
445        let composite = CompositeCredentialProvider::new();
446        let result = composite.get_credential("any_key");
447        assert!(matches!(result, Err(CredentialError::NotFound(_))));
448        assert!(composite.available_keys().is_empty());
449    }
450
451    #[test]
452    fn test_credential_error_display() {
453        let e = CredentialError::NotFound("MY_KEY".to_string());
454        assert!(e.to_string().contains("MY_KEY"));
455
456        let e = CredentialError::PermissionDenied("/path/to/file".to_string());
457        assert!(e.to_string().contains("permission denied"));
458
459        let e = CredentialError::ParseError("invalid json".to_string());
460        assert!(e.to_string().contains("parse error"));
461    }
462}