Skip to main content

grapsus_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!("Environment variable '{}' not set", var_name))
72        })?;
73
74        // Try JSON first, fall back to plain token
75        if value.trim().starts_with('{') {
76            Self::parse_json_credentials(&value)
77        } else {
78            Ok(Credentials::Token(value.trim().to_string()))
79        }
80    }
81
82    /// Parse credentials from content string
83    fn parse_credentials(content: &str, path: &Path) -> Result<Credentials, DnsProviderError> {
84        let trimmed = content.trim();
85
86        // Try JSON first
87        if trimmed.starts_with('{') {
88            return Self::parse_json_credentials(trimmed);
89        }
90
91        // Plain text token
92        if trimmed.is_empty() {
93            return Err(DnsProviderError::Credentials(format!(
94                "Credentials file '{}' is empty",
95                path.display()
96            )));
97        }
98
99        debug!(path = %path.display(), "Loaded credentials as plain text token");
100        Ok(Credentials::Token(trimmed.to_string()))
101    }
102
103    /// Parse JSON credentials
104    fn parse_json_credentials(json: &str) -> Result<Credentials, DnsProviderError> {
105        // Try multiple JSON formats
106        #[derive(Deserialize)]
107        struct TokenFormat {
108            token: Option<String>,
109            api_token: Option<String>,
110        }
111
112        #[derive(Deserialize)]
113        struct KeySecretFormat {
114            api_key: String,
115            api_secret: String,
116        }
117
118        #[derive(Deserialize)]
119        struct ApiKeyOnlyFormat {
120            api_key: Option<String>,
121        }
122
123        // Try key+secret format first (more specific)
124        if let Ok(parsed) = serde_json::from_str::<KeySecretFormat>(json) {
125            debug!("Loaded credentials as JSON key+secret");
126            return Ok(Credentials::KeySecret {
127                key: parsed.api_key,
128                secret: parsed.api_secret,
129            });
130        }
131
132        // Try token-only format
133        if let Ok(parsed) = serde_json::from_str::<TokenFormat>(json) {
134            if let Some(token) = parsed.token.or(parsed.api_token) {
135                debug!("Loaded credentials as JSON token");
136                return Ok(Credentials::Token(token));
137            }
138        }
139
140        // Try api_key-only format (some providers use api_key as token)
141        if let Ok(parsed) = serde_json::from_str::<ApiKeyOnlyFormat>(json) {
142            if let Some(key) = parsed.api_key {
143                debug!("Loaded credentials as JSON api_key token");
144                return Ok(Credentials::Token(key));
145            }
146        }
147
148        Err(DnsProviderError::Credentials(
149            "Invalid JSON credentials format. Expected {\"token\": \"...\"} or {\"api_key\": \"...\", \"api_secret\": \"...\"}".to_string()
150        ))
151    }
152}
153
154/// Credential types supported by DNS providers
155#[derive(Debug, Clone)]
156pub enum Credentials {
157    /// Single API token
158    Token(String),
159    /// API key and secret pair
160    KeySecret { key: String, secret: String },
161}
162
163impl Credentials {
164    /// Get the token if this is a Token credential
165    pub fn token(&self) -> Option<&str> {
166        match self {
167            Credentials::Token(t) => Some(t),
168            Credentials::KeySecret { .. } => None,
169        }
170    }
171
172    /// Get the key if this is a KeySecret credential
173    pub fn key(&self) -> Option<&str> {
174        match self {
175            Credentials::KeySecret { key, .. } => Some(key),
176            Credentials::Token(_) => None,
177        }
178    }
179
180    /// Get the secret if this is a KeySecret credential
181    pub fn secret(&self) -> Option<&str> {
182        match self {
183            Credentials::KeySecret { secret, .. } => Some(secret),
184            Credentials::Token(_) => None,
185        }
186    }
187
188    /// Returns the token or key (for providers that use either)
189    pub fn as_bearer_token(&self) -> &str {
190        match self {
191            Credentials::Token(t) => t,
192            Credentials::KeySecret { key, .. } => key,
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use std::io::Write;
201    use tempfile::NamedTempFile;
202
203    #[test]
204    fn test_load_json_token() {
205        let mut file = NamedTempFile::new().unwrap();
206        writeln!(file, r#"{{"token": "test-token-123"}}"#).unwrap();
207
208        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
209        assert_eq!(creds.token(), Some("test-token-123"));
210    }
211
212    #[test]
213    fn test_load_json_api_token() {
214        let mut file = NamedTempFile::new().unwrap();
215        writeln!(file, r#"{{"api_token": "api-token-456"}}"#).unwrap();
216
217        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
218        assert_eq!(creds.token(), Some("api-token-456"));
219    }
220
221    #[test]
222    fn test_load_json_key_secret() {
223        let mut file = NamedTempFile::new().unwrap();
224        writeln!(
225            file,
226            r#"{{"api_key": "key123", "api_secret": "secret456"}}"#
227        )
228        .unwrap();
229
230        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
231        assert_eq!(creds.key(), Some("key123"));
232        assert_eq!(creds.secret(), Some("secret456"));
233    }
234
235    #[test]
236    fn test_load_plain_text() {
237        let mut file = NamedTempFile::new().unwrap();
238        writeln!(file, "plain-text-token").unwrap();
239
240        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
241        assert_eq!(creds.token(), Some("plain-text-token"));
242    }
243
244    #[test]
245    fn test_load_plain_text_with_whitespace() {
246        let mut file = NamedTempFile::new().unwrap();
247        writeln!(file, "  token-with-spaces  \n").unwrap();
248
249        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
250        assert_eq!(creds.token(), Some("token-with-spaces"));
251    }
252
253    #[test]
254    fn test_empty_file_error() {
255        let file = NamedTempFile::new().unwrap();
256        let result = CredentialLoader::load_from_file(file.path());
257        assert!(result.is_err());
258    }
259
260    #[test]
261    fn test_invalid_json_error() {
262        let mut file = NamedTempFile::new().unwrap();
263        writeln!(file, r#"{{"invalid": "format"}}"#).unwrap();
264
265        let result = CredentialLoader::load_from_file(file.path());
266        assert!(result.is_err());
267    }
268
269    #[test]
270    fn test_load_json_api_key_only() {
271        let mut file = NamedTempFile::new().unwrap();
272        writeln!(file, r#"{{"api_key": "just-a-key"}}"#).unwrap();
273
274        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
275        // Without api_secret, api_key is treated as a token
276        assert_eq!(creds.token(), Some("just-a-key"));
277    }
278
279    #[test]
280    fn test_load_json_with_extra_fields() {
281        let mut file = NamedTempFile::new().unwrap();
282        writeln!(
283            file,
284            r#"{{"token": "my-token", "extra": "field", "another": 123}}"#
285        )
286        .unwrap();
287
288        let creds = CredentialLoader::load_from_file(file.path()).unwrap();
289        assert_eq!(creds.token(), Some("my-token"));
290    }
291
292    #[test]
293    fn test_credentials_as_bearer_token() {
294        let token_creds = Credentials::Token("my-token".to_string());
295        assert_eq!(token_creds.as_bearer_token(), "my-token");
296
297        let key_secret_creds = Credentials::KeySecret {
298            key: "my-key".to_string(),
299            secret: "my-secret".to_string(),
300        };
301        assert_eq!(key_secret_creds.as_bearer_token(), "my-key");
302    }
303
304    #[test]
305    fn test_credentials_accessors() {
306        let token_creds = Credentials::Token("my-token".to_string());
307        assert_eq!(token_creds.token(), Some("my-token"));
308        assert_eq!(token_creds.key(), None);
309        assert_eq!(token_creds.secret(), None);
310
311        let key_secret_creds = Credentials::KeySecret {
312            key: "my-key".to_string(),
313            secret: "my-secret".to_string(),
314        };
315        assert_eq!(key_secret_creds.token(), None);
316        assert_eq!(key_secret_creds.key(), Some("my-key"));
317        assert_eq!(key_secret_creds.secret(), Some("my-secret"));
318    }
319
320    #[test]
321    fn test_load_from_env() {
322        std::env::set_var("TEST_DNS_TOKEN_12345", "env-token-value");
323
324        let creds = CredentialLoader::load_from_env("TEST_DNS_TOKEN_12345").unwrap();
325        assert_eq!(creds.token(), Some("env-token-value"));
326
327        std::env::remove_var("TEST_DNS_TOKEN_12345");
328    }
329
330    #[test]
331    fn test_load_from_env_json() {
332        std::env::set_var("TEST_DNS_JSON_12345", r#"{"token": "json-env-token"}"#);
333
334        let creds = CredentialLoader::load_from_env("TEST_DNS_JSON_12345").unwrap();
335        assert_eq!(creds.token(), Some("json-env-token"));
336
337        std::env::remove_var("TEST_DNS_JSON_12345");
338    }
339
340    #[test]
341    fn test_load_from_env_not_set() {
342        let result = CredentialLoader::load_from_env("NONEXISTENT_VAR_12345");
343        assert!(result.is_err());
344    }
345
346    #[test]
347    fn test_nonexistent_file() {
348        let result = CredentialLoader::load_from_file(std::path::Path::new(
349            "/nonexistent/path/to/creds.json",
350        ));
351        assert!(result.is_err());
352    }
353
354    #[test]
355    fn test_whitespace_only_file() {
356        let mut file = NamedTempFile::new().unwrap();
357        writeln!(file, "   \n\t  \n").unwrap();
358
359        let result = CredentialLoader::load_from_file(file.path());
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn test_malformed_json() {
365        let mut file = NamedTempFile::new().unwrap();
366        writeln!(file, r#"{{"token": "unclosed"#).unwrap();
367
368        let result = CredentialLoader::load_from_file(file.path());
369        // Malformed JSON starting with '{' will fail JSON parsing and be treated as plain text
370        // The result depends on how the parsing handles it
371        assert!(result.is_err());
372    }
373}