Skip to main content

pipedash_core/infrastructure/config/
encrypted_config.rs

1use aes_gcm::{
2    aead::{
3        Aead,
4        KeyInit,
5    },
6    Aes256Gcm,
7    Nonce,
8};
9use base64::{
10    engine::general_purpose::STANDARD as BASE64,
11    Engine as _,
12};
13use serde::{
14    Deserialize,
15    Deserializer,
16    Serialize,
17    Serializer,
18};
19
20const CONFIG_ENCRYPTION_SALT: &[u8] = b"pipedash-config-encrypt-v1";
21
22#[derive(Debug, Clone, Default)]
23pub struct EncryptedValue {
24    pub plaintext: Option<String>,
25    pub encrypted_repr: Option<String>,
26}
27
28impl EncryptedValue {
29    pub fn from_plaintext(value: String) -> Self {
30        Self {
31            plaintext: if value.is_empty() { None } else { Some(value) },
32            encrypted_repr: None,
33        }
34    }
35
36    pub fn empty() -> Self {
37        Self {
38            plaintext: None,
39            encrypted_repr: None,
40        }
41    }
42
43    pub fn is_set(&self) -> bool {
44        self.plaintext.is_some() || self.encrypted_repr.is_some()
45    }
46
47    pub fn is_encrypted(&self) -> bool {
48        self.encrypted_repr.is_some()
49    }
50
51    pub fn get(&self, password: &str) -> Result<Option<String>, String> {
52        if let Some(ref plain) = self.plaintext {
53            return Ok(Some(plain.clone()));
54        }
55
56        if let Some(ref encrypted) = self.encrypted_repr {
57            return Self::decrypt_value(encrypted, password).map(Some);
58        }
59
60        Ok(None)
61    }
62
63    pub fn get_plaintext(&self) -> Option<&str> {
64        self.plaintext.as_deref()
65    }
66
67    pub fn encrypt(plaintext: &str, password: &str) -> Result<String, String> {
68        let key = derive_config_key(password);
69        let cipher = Aes256Gcm::new_from_slice(&key)
70            .map_err(|e| format!("Failed to create cipher: {}", e))?;
71
72        let nonce_bytes: [u8; 12] = rand::random();
73        let nonce = Nonce::from(nonce_bytes);
74
75        let ciphertext = cipher
76            .encrypt(&nonce, plaintext.as_bytes())
77            .map_err(|e| format!("Encryption failed: {}", e))?;
78
79        Ok(format!(
80            "enc:v1:{}:{}",
81            BASE64.encode(nonce_bytes),
82            BASE64.encode(ciphertext)
83        ))
84    }
85
86    pub fn encrypt_value(plaintext: &str, password: &str) -> Result<Self, String> {
87        let encrypted_repr = Self::encrypt(plaintext, password)?;
88        Ok(Self {
89            plaintext: None,
90            encrypted_repr: Some(encrypted_repr),
91        })
92    }
93
94    fn decrypt_value(encrypted: &str, password: &str) -> Result<String, String> {
95        let parts: Vec<&str> = encrypted.split(':').collect();
96        if parts.len() != 4 {
97            return Err("Invalid encrypted format: expected 4 parts".into());
98        }
99        if parts[0] != "enc" {
100            return Err("Invalid encrypted format: must start with 'enc'".into());
101        }
102        if parts[1] != "v1" {
103            return Err(format!(
104                "Unsupported encryption version: {}. Only v1 is supported.",
105                parts[1]
106            ));
107        }
108
109        let nonce_bytes = BASE64
110            .decode(parts[2])
111            .map_err(|e| format!("Invalid nonce encoding: {}", e))?;
112        let ciphertext = BASE64
113            .decode(parts[3])
114            .map_err(|e| format!("Invalid ciphertext encoding: {}", e))?;
115
116        if nonce_bytes.len() != 12 {
117            return Err(format!(
118                "Invalid nonce length: expected 12 bytes, got {}",
119                nonce_bytes.len()
120            ));
121        }
122
123        let key = derive_config_key(password);
124        let cipher = Aes256Gcm::new_from_slice(&key)
125            .map_err(|e| format!("Failed to create cipher: {}", e))?;
126
127        let nonce_array: [u8; 12] = nonce_bytes
128            .try_into()
129            .map_err(|_| "Invalid nonce length".to_string())?;
130        let nonce = Nonce::from(nonce_array);
131
132        let plaintext = cipher
133            .decrypt(&nonce, ciphertext.as_ref())
134            .map_err(|_| "Decryption failed - wrong password or corrupted data".to_string())?;
135
136        String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in decrypted value: {}", e))
137    }
138}
139
140fn derive_config_key(password: &str) -> [u8; 32] {
141    use argon2::{
142        Argon2,
143        ParamsBuilder,
144    };
145
146    let mut output = [0u8; 32];
147
148    let params = ParamsBuilder::new()
149        .m_cost(4096) // 4 MiB memory
150        .t_cost(1) // 1 iteration
151        .p_cost(1) // 1 thread
152        .output_len(32)
153        .build()
154        .expect("Invalid Argon2 parameters");
155
156    let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
157
158    argon2
159        .hash_password_into(password.as_bytes(), CONFIG_ENCRYPTION_SALT, &mut output)
160        .expect("Failed to derive config encryption key");
161
162    output
163}
164
165impl Serialize for EncryptedValue {
166    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
167    where
168        S: Serializer,
169    {
170        if let Some(ref encrypted) = self.encrypted_repr {
171            return serializer.serialize_str(encrypted);
172        }
173        match &self.plaintext {
174            Some(s) => serializer.serialize_str(s),
175            None => serializer.serialize_str(""),
176        }
177    }
178}
179
180impl<'de> Deserialize<'de> for EncryptedValue {
181    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182    where
183        D: Deserializer<'de>,
184    {
185        let s = String::deserialize(deserializer)?;
186
187        if s.starts_with("enc:v1:") {
188            Ok(Self {
189                plaintext: None,
190                encrypted_repr: Some(s),
191            })
192        } else {
193            Ok(Self {
194                plaintext: if s.is_empty() { None } else { Some(s) },
195                encrypted_repr: None,
196            })
197        }
198    }
199}
200
201pub fn is_encrypted_format(s: &str) -> bool {
202    s.starts_with("enc:v1:")
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_encrypt_decrypt() {
211        let plaintext = "postgres://user:pass@localhost/db";
212        let password = "my-secure-password";
213
214        let encrypted = EncryptedValue::encrypt(plaintext, password).unwrap();
215        assert!(encrypted.starts_with("enc:v1:"));
216
217        let value = EncryptedValue {
218            plaintext: None,
219            encrypted_repr: Some(encrypted),
220        };
221
222        let decrypted = value.get(password).unwrap().unwrap();
223        assert_eq!(decrypted, plaintext);
224    }
225
226    #[test]
227    fn test_wrong_password() {
228        let plaintext = "secret-value";
229        let encrypted = EncryptedValue::encrypt(plaintext, "correct-password").unwrap();
230
231        let value = EncryptedValue {
232            plaintext: None,
233            encrypted_repr: Some(encrypted),
234        };
235
236        let result = value.get("wrong-password");
237        assert!(result.is_err());
238    }
239
240    #[test]
241    fn test_plaintext_passthrough() {
242        let value = EncryptedValue::from_plaintext("plain-value".to_string());
243        let result = value.get("any-password").unwrap().unwrap();
244        assert_eq!(result, "plain-value");
245    }
246
247    #[test]
248    fn test_empty_value() {
249        let value = EncryptedValue::empty();
250        assert!(!value.is_set());
251        assert!(value.get("password").unwrap().is_none());
252    }
253
254    #[test]
255    fn test_serde_plaintext() {
256        let value = EncryptedValue::from_plaintext("my-value".to_string());
257        let serialized = serde_json::to_string(&value).unwrap();
258        assert_eq!(serialized, "\"my-value\"");
259
260        let deserialized: EncryptedValue = serde_json::from_str(&serialized).unwrap();
261        assert_eq!(deserialized.plaintext.as_deref(), Some("my-value"));
262    }
263
264    #[test]
265    fn test_serde_encrypted() {
266        let encrypted_str = "enc:v1:AAAAAAAAAAAAAAAA:BBBBBBBB";
267        let serialized = format!("\"{}\"", encrypted_str);
268
269        let deserialized: EncryptedValue = serde_json::from_str(&serialized).unwrap();
270        assert!(deserialized.is_encrypted());
271        assert_eq!(deserialized.encrypted_repr.as_deref(), Some(encrypted_str));
272    }
273
274    #[test]
275    fn test_is_encrypted_format() {
276        assert!(is_encrypted_format("enc:v1:nonce:ciphertext"));
277        assert!(!is_encrypted_format("plain-value"));
278        assert!(!is_encrypted_format("enc:v2:something"));
279    }
280
281    #[test]
282    fn test_encrypt_value() {
283        let value = EncryptedValue::encrypt_value("my-secret", "my-password").unwrap();
284        assert!(value.is_encrypted());
285        assert!(value.encrypted_repr.is_some());
286        assert!(value.plaintext.is_none());
287
288        let decrypted = value.get("my-password").unwrap().unwrap();
289        assert_eq!(decrypted, "my-secret");
290    }
291}