Skip to main content

sentinel_proxy/acme/dns/
credentials.rs

1//! Secure credential loading for DNS providers
2//!
3//! Supports loading credentials from:
4//! - JSON files with various key names
5//! - Environment variables
6//! - Plain text files (single token)
7
8use std::fs;
9use std::os::unix::fs::PermissionsExt;
10use std::path::Path;
11
12use serde::Deserialize;
13use tracing::{debug, warn};
14
15use super::provider::DnsProviderError;
16
17/// Credential loader for DNS provider authentication
18#[derive(Debug, Default)]
19pub struct CredentialLoader;
20
21impl CredentialLoader {
22    /// Load credentials from a file
23    ///
24    /// Supports multiple formats:
25    /// - JSON: `{"token": "..."}` or `{"api_key": "...", "api_secret": "..."}`
26    /// - Plain text: Entire file content is the token
27    ///
28    /// # Security
29    ///
30    /// Validates file permissions on Unix (must be 0600 or 0400)
31    pub fn load_from_file(path: &Path) -> Result<Credentials, DnsProviderError> {
32        // Check file permissions on Unix
33        #[cfg(unix)]
34        {
35            let metadata = fs::metadata(path).map_err(|e| {
36                DnsProviderError::Credentials(format!(
37                    "Failed to read credentials file '{}': {}",
38                    path.display(),
39                    e
40                ))
41            })?;
42
43            let mode = metadata.permissions().mode();
44            let file_mode = mode & 0o777;
45
46            // Only owner should have access (0600 or 0400)
47            if file_mode & 0o077 != 0 {
48                warn!(
49                    path = %path.display(),
50                    mode = format!("{:o}", file_mode),
51                    "Credentials file has overly permissive permissions (should be 0600 or 0400)"
52                );
53                // Continue anyway, but log warning
54            }
55        }
56
57        let content = fs::read_to_string(path).map_err(|e| {
58            DnsProviderError::Credentials(format!(
59                "Failed to read credentials file '{}': {}",
60                path.display(),
61                e
62            ))
63        })?;
64
65        Self::parse_credentials(&content, path)
66    }
67
68    /// Load credentials from an environment variable
69    pub fn load_from_env(var_name: &str) -> Result<Credentials, DnsProviderError> {
70        let value = std::env::var(var_name).map_err(|_| {
71            DnsProviderError::Credentials(format!(
72                "Environment variable '{}' not set",
73                var_name
74            ))
75        })?;
76
77        // Try JSON first, fall back to plain token
78        if value.trim().starts_with('{') {
79            Self::parse_json_credentials(&value)
80        } else {
81            Ok(Credentials::Token(value.trim().to_string()))
82        }
83    }
84
85    /// Parse credentials from content string
86    fn parse_credentials(content: &str, path: &Path) -> Result<Credentials, DnsProviderError> {
87        let trimmed = content.trim();
88
89        // Try JSON first
90        if trimmed.starts_with('{') {
91            return Self::parse_json_credentials(trimmed);
92        }
93
94        // Plain text token
95        if trimmed.is_empty() {
96            return Err(DnsProviderError::Credentials(format!(
97                "Credentials file '{}' is empty",
98                path.display()
99            )));
100        }
101
102        debug!(path = %path.display(), "Loaded credentials as plain text token");
103        Ok(Credentials::Token(trimmed.to_string()))
104    }
105
106    /// Parse JSON credentials
107    fn parse_json_credentials(json: &str) -> Result<Credentials, DnsProviderError> {
108        // Try multiple JSON formats
109        #[derive(Deserialize)]
110        struct TokenFormat {
111            token: Option<String>,
112            api_token: Option<String>,
113        }
114
115        #[derive(Deserialize)]
116        struct KeySecretFormat {
117            api_key: String,
118            api_secret: String,
119        }
120
121        #[derive(Deserialize)]
122        struct ApiKeyOnlyFormat {
123            api_key: Option<String>,
124        }
125
126        // Try key+secret format first (more specific)
127        if let Ok(parsed) = serde_json::from_str::<KeySecretFormat>(json) {
128            debug!("Loaded credentials as JSON key+secret");
129            return Ok(Credentials::KeySecret {
130                key: parsed.api_key,
131                secret: parsed.api_secret,
132            });
133        }
134
135        // Try token-only format
136        if let Ok(parsed) = serde_json::from_str::<TokenFormat>(json) {
137            if let Some(token) = parsed.token.or(parsed.api_token) {
138                debug!("Loaded credentials as JSON token");
139                return Ok(Credentials::Token(token));
140            }
141        }
142
143        // Try api_key-only format (some providers use api_key as token)
144        if let Ok(parsed) = serde_json::from_str::<ApiKeyOnlyFormat>(json) {
145            if let Some(key) = parsed.api_key {
146                debug!("Loaded credentials as JSON api_key token");
147                return Ok(Credentials::Token(key));
148            }
149        }
150
151        Err(DnsProviderError::Credentials(
152            "Invalid JSON credentials format. Expected {\"token\": \"...\"} or {\"api_key\": \"...\", \"api_secret\": \"...\"}".to_string()
153        ))
154    }
155}
156
157/// Credential types supported by DNS providers
158#[derive(Debug, Clone)]
159pub enum Credentials {
160    /// Single API token
161    Token(String),
162    /// API key and secret pair
163    KeySecret { key: String, secret: String },
164}
165
166impl Credentials {
167    /// Get the token if this is a Token credential
168    pub fn token(&self) -> Option<&str> {
169        match self {
170            Credentials::Token(t) => Some(t),
171            Credentials::KeySecret { .. } => None,
172        }
173    }
174
175    /// Get the key if this is a KeySecret credential
176    pub fn key(&self) -> Option<&str> {
177        match self {
178            Credentials::KeySecret { key, .. } => Some(key),
179            Credentials::Token(_) => None,
180        }
181    }
182
183    /// Get the secret if this is a KeySecret credential
184    pub fn secret(&self) -> Option<&str> {
185        match self {
186            Credentials::KeySecret { secret, .. } => Some(secret),
187            Credentials::Token(_) => None,
188        }
189    }
190
191    /// Returns the token or key (for providers that use either)
192    pub fn as_bearer_token(&self) -> &str {
193        match self {
194            Credentials::Token(t) => t,
195            Credentials::KeySecret { key, .. } => key,
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::io::Write;
204    use tempfile::NamedTempFile;
205
206    #[test]
207    fn test_load_json_token() {
208        let mut file = NamedTempFile::new().unwrap();
209        writeln!(file, r#"{{"token": "test-token-123"}}"#).unwrap();
210
211        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
212        assert_eq!(creds.token(), Some("test-token-123"));
213    }
214
215    #[test]
216    fn test_load_json_api_token() {
217        let mut file = NamedTempFile::new().unwrap();
218        writeln!(file, r#"{{"api_token": "api-token-456"}}"#).unwrap();
219
220        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
221        assert_eq!(creds.token(), Some("api-token-456"));
222    }
223
224    #[test]
225    fn test_load_json_key_secret() {
226        let mut file = NamedTempFile::new().unwrap();
227        writeln!(file, r#"{{"api_key": "key123", "api_secret": "secret456"}}"#).unwrap();
228
229        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
230        assert_eq!(creds.key(), Some("key123"));
231        assert_eq!(creds.secret(), Some("secret456"));
232    }
233
234    #[test]
235    fn test_load_plain_text() {
236        let mut file = NamedTempFile::new().unwrap();
237        writeln!(file, "plain-text-token").unwrap();
238
239        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
240        assert_eq!(creds.token(), Some("plain-text-token"));
241    }
242
243    #[test]
244    fn test_load_plain_text_with_whitespace() {
245        let mut file = NamedTempFile::new().unwrap();
246        writeln!(file, "  token-with-spaces  \n").unwrap();
247
248        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
249        assert_eq!(creds.token(), Some("token-with-spaces"));
250    }
251
252    #[test]
253    fn test_empty_file_error() {
254        let file = NamedTempFile::new().unwrap();
255        let result = CredentialLoader::load_from_file(file.path());
256        assert!(result.is_err());
257    }
258
259    #[test]
260    fn test_invalid_json_error() {
261        let mut file = NamedTempFile::new().unwrap();
262        writeln!(file, r#"{{"invalid": "format"}}"#).unwrap();
263
264        let result = CredentialLoader::load_from_file(file.path());
265        assert!(result.is_err());
266    }
267
268    #[test]
269    fn test_load_json_api_key_only() {
270        let mut file = NamedTempFile::new().unwrap();
271        writeln!(file, r#"{{"api_key": "just-a-key"}}"#).unwrap();
272
273        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
274        // Without api_secret, api_key is treated as a token
275        assert_eq!(creds.token(), Some("just-a-key"));
276    }
277
278    #[test]
279    fn test_load_json_with_extra_fields() {
280        let mut file = NamedTempFile::new().unwrap();
281        writeln!(file, r#"{{"token": "my-token", "extra": "field", "another": 123}}"#).unwrap();
282
283        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
284        assert_eq!(creds.token(), Some("my-token"));
285    }
286
287    #[test]
288    fn test_credentials_as_bearer_token() {
289        let token_creds = Credentials::Token("my-token".to_string());
290        assert_eq!(token_creds.as_bearer_token(), "my-token");
291
292        let key_secret_creds = Credentials::KeySecret {
293            key: "my-key".to_string(),
294            secret: "my-secret".to_string(),
295        };
296        assert_eq!(key_secret_creds.as_bearer_token(), "my-key");
297    }
298
299    #[test]
300    fn test_credentials_accessors() {
301        let token_creds = Credentials::Token("my-token".to_string());
302        assert_eq!(token_creds.token(), Some("my-token"));
303        assert_eq!(token_creds.key(), None);
304        assert_eq!(token_creds.secret(), None);
305
306        let key_secret_creds = Credentials::KeySecret {
307            key: "my-key".to_string(),
308            secret: "my-secret".to_string(),
309        };
310        assert_eq!(key_secret_creds.token(), None);
311        assert_eq!(key_secret_creds.key(), Some("my-key"));
312        assert_eq!(key_secret_creds.secret(), Some("my-secret"));
313    }
314
315    #[test]
316    fn test_load_from_env() {
317        std::env::set_var("TEST_DNS_TOKEN_12345", "env-token-value");
318
319        let creds = CredentialLoader::load_from_env("TEST_DNS_TOKEN_12345").unwrap();
320        assert_eq!(creds.token(), Some("env-token-value"));
321
322        std::env::remove_var("TEST_DNS_TOKEN_12345");
323    }
324
325    #[test]
326    fn test_load_from_env_json() {
327        std::env::set_var("TEST_DNS_JSON_12345", r#"{"token": "json-env-token"}"#);
328
329        let creds = CredentialLoader::load_from_env("TEST_DNS_JSON_12345").unwrap();
330        assert_eq!(creds.token(), Some("json-env-token"));
331
332        std::env::remove_var("TEST_DNS_JSON_12345");
333    }
334
335    #[test]
336    fn test_load_from_env_not_set() {
337        let result = CredentialLoader::load_from_env("NONEXISTENT_VAR_12345");
338        assert!(result.is_err());
339    }
340
341    #[test]
342    fn test_nonexistent_file() {
343        let result = CredentialLoader::load_from_file(std::path::Path::new("/nonexistent/path/to/creds.json"));
344        assert!(result.is_err());
345    }
346
347    #[test]
348    fn test_whitespace_only_file() {
349        let mut file = NamedTempFile::new().unwrap();
350        writeln!(file, "   \n\t  \n").unwrap();
351
352        let result = CredentialLoader::load_from_file(file.path());
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_malformed_json() {
358        let mut file = NamedTempFile::new().unwrap();
359        writeln!(file, r#"{{"token": "unclosed"#).unwrap();
360
361        let result = CredentialLoader::load_from_file(file.path());
362        // Malformed JSON starting with '{' will fail JSON parsing and be treated as plain text
363        // The result depends on how the parsing handles it
364        assert!(result.is_err());
365    }
366}