rust_config_secrets/
config.rs

1use crate::crypto;
2use crate::error::ConfigSecretsError;
3use aes_gcm::{Aes256Gcm, KeyInit};
4use bs58::{decode, encode};
5use rand::{RngCore, thread_rng};
6use std::fs;
7use std::path::Path;
8
9fn get_cipher(key: &str) -> Result<Aes256Gcm, ConfigSecretsError> {
10    let key_bytes = decode(key)
11        .into_vec()
12        .map_err(|e| ConfigSecretsError::InvalidEncoding(e.to_string()))?;
13
14    if key_bytes.len() != 32 {
15        return Err(ConfigSecretsError::InvalidKeyLength(key_bytes.len()));
16    }
17
18    Aes256Gcm::new_from_slice(&key_bytes)
19        .map_err(|_| ConfigSecretsError::InvalidKeyLength(key_bytes.len()))
20}
21
22/// Generates a random 32-byte AES key and returns it as an alphanumeric encoded string.
23pub fn generate_key() -> String {
24    let mut key = [0u8; 32];
25    thread_rng().fill_bytes(&mut key);
26    encode(&key).into_string()
27}
28
29/// Decrypts all `SECRET(...)` blocks in the provided string.
30pub fn decrypt_secrets(config: &str, key: &str) -> Result<String, ConfigSecretsError> {
31    let cipher = get_cipher(key)?;
32    let marker = "SECRET(";
33    let mut output = String::new();
34    let mut cursor = 0;
35
36    while let Some(start_offset) = config[cursor..].find(marker) {
37        let absolute_start = cursor + start_offset;
38        output.push_str(&config[cursor..absolute_start]);
39
40        match config[absolute_start..].find(')') {
41            Some(end_offset) => {
42                let absolute_end = absolute_start + end_offset;
43                let content_str = &config[absolute_start + marker.len()..absolute_end];
44
45                let ciphertext = decode(content_str)
46                    .into_vec()
47                    .map_err(|e| ConfigSecretsError::InvalidEncoding(e.to_string()))?;
48
49                let decrypted = crypto::decrypt(&ciphertext, &cipher)?;
50                output.push_str(&decrypted);
51
52                cursor = absolute_end + 1;
53            }
54            None => return Err(ConfigSecretsError::UnclosedBlock("SECRET".to_string())),
55        }
56    }
57    output.push_str(&config[cursor..]);
58    Ok(output)
59}
60
61/// Decrypts a configuration file and returns the content as a string.
62pub fn decrypt_file<P: AsRef<Path>>(path: P, key: &str) -> Result<String, ConfigSecretsError> {
63    let content =
64        fs::read_to_string(path).map_err(|e| ConfigSecretsError::IoError(e.to_string()))?;
65    decrypt_secrets(&content, key)
66}
67
68/// Encrypts all `ENCRYPT(...)` blocks in the provided string, converting them to `SECRET(...)`.
69pub fn encrypt_secrets(config: &str, key: &str) -> Result<String, ConfigSecretsError> {
70    let cipher = get_cipher(key)?;
71    let marker = "ENCRYPT(";
72    let mut output = String::new();
73    let mut cursor = 0;
74
75    while let Some(start_offset) = config[cursor..].find(marker) {
76        let absolute_start = cursor + start_offset;
77        output.push_str(&config[cursor..absolute_start]);
78
79        match config[absolute_start..].find(')') {
80            Some(end_offset) => {
81                let absolute_end = absolute_start + end_offset;
82                let content = &config[absolute_start + marker.len()..absolute_end];
83
84                let encrypted_bytes = crypto::encrypt(content, &cipher)?;
85                let encoded_str = encode(&encrypted_bytes).into_string();
86
87                output.push_str("SECRET(");
88                output.push_str(&encoded_str);
89                output.push(')');
90
91                cursor = absolute_end + 1;
92            }
93            None => return Err(ConfigSecretsError::UnclosedBlock("ENCRYPT".to_string())),
94        }
95    }
96    output.push_str(&config[cursor..]);
97    Ok(output)
98}
99
100/// Encrypts secrets in a string and writes the result to a file.
101pub fn encrypt_secrets_to_file<P: AsRef<Path>>(
102    config: &str,
103    output_path: P,
104    key: &str,
105) -> Result<(), ConfigSecretsError> {
106    let encrypted_content = encrypt_secrets(config, key)?;
107    fs::write(output_path, encrypted_content)
108        .map_err(|e| ConfigSecretsError::IoError(e.to_string()))
109}
110
111/// Reads a file, encrypts its secrets, and writes the result to a different output file.
112pub fn encrypt_file<P: AsRef<Path>, Q: AsRef<Path>>(
113    input_path: P,
114    output_path: Q,
115    key: &str,
116) -> Result<(), ConfigSecretsError> {
117    let content =
118        fs::read_to_string(input_path).map_err(|e| ConfigSecretsError::IoError(e.to_string()))?;
119    encrypt_secrets_to_file(&content, output_path, key)
120}
121
122/// Reads a file, encrypts its secrets, and overwrites the file with the result.
123pub fn encrypt_file_in_place<P: AsRef<Path>>(path: P, key: &str) -> Result<(), ConfigSecretsError> {
124    let content =
125        fs::read_to_string(&path).map_err(|e| ConfigSecretsError::IoError(e.to_string()))?;
126    // Only write if encryption changes something or succeeds
127    let encrypted_content = encrypt_secrets(&content, key)?;
128    fs::write(path, encrypted_content).map_err(|e| ConfigSecretsError::IoError(e.to_string()))
129}
130
131/// Encrypts a single value and returns the encoded ciphertext.
132pub fn encrypt_value(value: &str, key: &str) -> Result<String, ConfigSecretsError> {
133    let cipher = get_cipher(key)?;
134    let encrypted_bytes = crypto::encrypt(value, &cipher)?;
135    Ok(encode(&encrypted_bytes).into_string())
136}
137
138/// Decrypts a single value. Accepts either `SECRET(...)` format or raw encoded string.
139pub fn decrypt_value(input: &str, key: &str) -> Result<String, ConfigSecretsError> {
140    let cipher = get_cipher(key)?;
141
142    // Check if it's wrapped in SECRET(...)
143    let inner_content = if input.trim().starts_with("SECRET(") && input.trim().ends_with(')') {
144        let trimmed = input.trim();
145        &trimmed["SECRET(".len()..trimmed.len() - 1]
146    } else {
147        input.trim()
148    };
149
150    let ciphertext = decode(inner_content)
151        .into_vec()
152        .map_err(|e| ConfigSecretsError::InvalidEncoding(e.to_string()))?;
153
154    crypto::decrypt(&ciphertext, &cipher)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_encrypt_decrypt_value() {
163        let key = generate_key();
164        let secret = "my-password";
165
166        // Test Encrypt
167        let encrypted = encrypt_value(secret, &key).unwrap();
168        // Should be alphanumeric, not wrapped
169        assert!(!encrypted.starts_with("SECRET("));
170        assert!(encrypted.chars().all(|c| c.is_alphanumeric()));
171
172        // Test Decrypt with raw string
173        let decrypted = decrypt_value(&encrypted, &key).unwrap();
174        assert_eq!(decrypted, secret);
175
176        // Test Decrypt with wrapper
177        let wrapped = format!("SECRET({})", encrypted);
178        let decrypted_wrapped = decrypt_value(&wrapped, &key).unwrap();
179        assert_eq!(decrypted_wrapped, secret);
180    }
181
182    #[test]
183    fn test_decrypt_value_invalid_encoding() {
184        let key = generate_key();
185        let err = decrypt_value("invalid-encoding!", &key).unwrap_err();
186        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
187    }
188
189    #[test]
190    fn test_generate_key() {
191        let key = generate_key();
192        assert!(!key.is_empty());
193        // Verify it's valid alphanumeric
194        let decoded = decode(&key).into_vec();
195        assert!(decoded.is_ok());
196        // Check decoded length is 32 bytes
197        assert_eq!(decoded.unwrap().len(), 32);
198    }
199
200    #[test]
201    fn test_encrypt_secrets() {
202        let key = &generate_key();
203        let input = r#"{"pass": "ENCRYPT(my_secret)"}"#;
204        let output = encrypt_secrets(input, key).unwrap();
205
206        assert!(output.contains("SECRET("));
207        assert!(!output.contains("ENCRYPT("));
208        assert!(!output.contains("my_secret")); // Plaintext should be gone
209    }
210
211    #[test]
212    fn test_decrypt_secrets() {
213        let key = &generate_key();
214        // First encrypt to get a valid secret block
215        let input = r#"{"pass": "ENCRYPT(my_secret)"}"#;
216        let encrypted = encrypt_secrets(input, key).unwrap();
217
218        // Then decrypt
219        let decrypted = decrypt_secrets(&encrypted, key).unwrap();
220        assert!(decrypted.contains(r#""pass": "my_secret""#));
221    }
222
223    #[test]
224    fn test_encrypt_secrets_to_file() {
225        let key = &generate_key();
226        let dir = std::env::temp_dir();
227        let path = dir.join("secrets_out.json");
228        let input = r#"key = "ENCRYPT(value)""#;
229
230        encrypt_secrets_to_file(input, &path, key).unwrap();
231
232        let content = fs::read_to_string(&path).unwrap();
233        assert!(content.contains("SECRET("));
234        assert!(!content.contains("value"));
235
236        let _ = fs::remove_file(path);
237    }
238
239    #[test]
240    fn test_encrypt_file() {
241        let key = &generate_key();
242        let dir = std::env::temp_dir();
243        let in_path = dir.join("in.json");
244        let out_path = dir.join("out.json");
245
246        fs::write(&in_path, r#"data: ENCRYPT(sensitive)"#).unwrap();
247
248        encrypt_file(&in_path, &out_path, key).unwrap();
249
250        let out_content = fs::read_to_string(&out_path).unwrap();
251        assert!(out_content.contains("SECRET("));
252
253        let _ = fs::remove_file(in_path);
254        let _ = fs::remove_file(out_path);
255    }
256
257    #[test]
258    fn test_encrypt_file_in_place() {
259        let key = &generate_key();
260        let dir = std::env::temp_dir();
261        let path = dir.join("inplace_test.yaml");
262
263        fs::write(&path, "pass: ENCRYPT(word)").unwrap();
264
265        encrypt_file_in_place(&path, key).unwrap();
266
267        let content = fs::read_to_string(&path).unwrap();
268        assert!(content.contains("SECRET("));
269
270        // Verify decryption works
271        let decrypted = decrypt_secrets(&content, key).unwrap();
272        assert!(decrypted.contains("pass: word"));
273
274        let _ = fs::remove_file(path);
275    }
276
277    #[test]
278    fn test_decrypt_file() {
279        let key = &generate_key();
280        let dir = std::env::temp_dir();
281        let path = dir.join("decrypt_me.ini");
282
283        let content = r#"secret=ENCRYPT(hidden)"#;
284        let encrypted = encrypt_secrets(content, key).unwrap();
285        fs::write(&path, encrypted).unwrap();
286
287        let decrypted = decrypt_file(&path, key).unwrap();
288        assert!(decrypted.contains("secret=hidden"));
289
290        let _ = fs::remove_file(path);
291    }
292
293    #[test]
294    fn test_mixed_content() {
295        let key = &generate_key();
296        let input = r#"
297            visible = "true"
298            secret1 = "ENCRYPT(one)"
299            secret2 = "ENCRYPT(two)"
300            also_visible = 123
301        "#;
302
303        let encrypted = encrypt_secrets(input, key).unwrap();
304        let decrypted = decrypt_secrets(&encrypted, key).unwrap();
305
306        assert!(decrypted.contains(r#"visible = "true""#));
307        assert!(decrypted.contains(r#"secret1 = "one""#));
308        assert!(decrypted.contains(r#"secret2 = "two""#));
309        assert!(decrypted.contains(r#"also_visible = 123"#));
310    }
311
312    #[test]
313    fn test_invalid_key_encoding() {
314        assert!(matches!(
315            get_cipher("not-alphanumeric!"),
316            Err(ConfigSecretsError::InvalidEncoding(_))
317        ));
318    }
319
320    #[test]
321    fn test_invalid_key_length() {
322        // Valid encoding (2 chars -> 1 byte), but decodes to 1 byte
323        let key = encode(&vec![0u8]).into_string();
324        assert!(matches!(
325            get_cipher(&key),
326            Err(ConfigSecretsError::InvalidKeyLength(1))
327        ));
328    }
329
330    #[test]
331    fn test_unclosed_encrypt_block() {
332        let key = &generate_key();
333        let input = "val = ENCRYPT(oops";
334        let err = encrypt_secrets(input, key).unwrap_err();
335        assert_eq!(
336            err,
337            ConfigSecretsError::UnclosedBlock("ENCRYPT".to_string())
338        );
339    }
340
341    #[test]
342    fn test_unclosed_secret_block() {
343        let key = &generate_key();
344        let input = "val = SECRET(oops";
345        let err = decrypt_secrets(input, key).unwrap_err();
346        assert_eq!(err, ConfigSecretsError::UnclosedBlock("SECRET".to_string()));
347    }
348
349    #[test]
350    fn test_invalid_encoding_in_secret() {
351        let key = &generate_key();
352        let input = "val = SECRET(!!!)";
353        let err = decrypt_secrets(input, key).unwrap_err();
354        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
355    }
356
357    #[test]
358    fn test_decrypt_secrets_invalid_key() {
359        let err = decrypt_secrets("SECRET(val)", "invalid-key").unwrap_err();
360        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
361    }
362
363    #[test]
364    fn test_decrypt_secrets_decryption_failed() {
365        let key = generate_key();
366        // "2222" is valid Base58 but decodes to a very short byte array,
367        // which should trigger CiphertextTooShort.
368        let input = "pass = SECRET(2222)";
369        let err = decrypt_secrets(input, &key).unwrap_err();
370        assert!(matches!(
371            err,
372            ConfigSecretsError::CiphertextTooShort
373                | ConfigSecretsError::DecryptionFailed
374                | ConfigSecretsError::InvalidEncoding(_)
375        ));
376    }
377
378    #[test]
379    fn test_encrypt_secrets_invalid_key() {
380        let err = encrypt_secrets("ENCRYPT(val)", "invalid-key").unwrap_err();
381        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
382    }
383
384    #[test]
385    fn test_file_funcs_invalid_key() {
386        let bad_key = "invalid-key";
387        let dir = std::env::temp_dir();
388        let path = dir.join("dummy_config.txt");
389        fs::write(&path, "content").unwrap();
390
391        // decrypt_file
392        let err = decrypt_file(&path, bad_key).unwrap_err();
393        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
394
395        // encrypt_secrets_to_file
396        let err = encrypt_secrets_to_file("content", &path, bad_key).unwrap_err();
397        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
398
399        // encrypt_file
400        let err = encrypt_file(&path, &path, bad_key).unwrap_err();
401        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
402
403        // encrypt_file_in_place
404        let err = encrypt_file_in_place(&path, bad_key).unwrap_err();
405        assert!(matches!(err, ConfigSecretsError::InvalidEncoding(_)));
406
407        let _ = fs::remove_file(path);
408    }
409}