1use aes_gcm::{
5 aead::{rand_core::RngCore, Aead, AeadCore, KeyInit, OsRng},
6 Aes256Gcm, Nonce,
7};
8use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
9use gethostname::gethostname;
10use pbkdf2::pbkdf2_hmac;
11use sha2::Sha256;
12use thiserror::Error;
13
14const SERVICE_NAME: &str = "apcore-cli";
19const PBKDF2_SALT_V1: &[u8] = b"apcore-cli-config-v1";
23const PBKDF2_ITERATIONS: u32 = 600_000;
25const MIN_WIRE_LEN_V1: usize = 28;
27const PBKDF2_SALT_LEN_V2: usize = 16;
29const MIN_WIRE_LEN_V2: usize = PBKDF2_SALT_LEN_V2 + 28;
31
32#[derive(Debug, Error)]
38pub enum ConfigDecryptionError {
39 #[error("decryption failed: authentication tag mismatch or corrupt data")]
41 AuthTagMismatch,
42
43 #[error("decrypted data is not valid UTF-8")]
45 InvalidUtf8,
46
47 #[error("keyring error: {0}")]
49 KeyringError(String),
50
51 #[error("key derivation error: {0}")]
53 KdfError(String),
54}
55
56#[derive(Default)]
71pub struct ConfigEncryptor {
72 _force_aes: bool,
75}
76
77impl ConfigEncryptor {
78 pub fn new() -> Result<Self, ConfigDecryptionError> {
80 Ok(Self::default())
81 }
82
83 #[cfg(any(test, feature = "test-support"))]
87 pub fn new_forced_aes() -> Self {
88 Self { _force_aes: true }
89 }
90
91 #[allow(dead_code)]
93 pub(crate) fn keyring_available(&self) -> bool {
94 self._keyring_available()
95 }
96
97 pub fn store(&self, key: &str, value: &str) -> Result<String, ConfigDecryptionError> {
119 if self._keyring_available() {
120 let entry = keyring::Entry::new(SERVICE_NAME, key)
121 .map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
122 entry
123 .set_password(value)
124 .map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
125 Ok(format!("keyring:{key}"))
126 } else {
127 tracing::warn!("OS keyring unavailable. Using file-based encryption.");
128 let ciphertext = self._aes_encrypt_v2(value)?;
129 Ok(format!("enc:v2:{}", B64.encode(&ciphertext)))
130 }
131 }
132
133 pub fn retrieve(&self, config_value: &str, key: &str) -> Result<String, ConfigDecryptionError> {
141 if let Some(ref_key) = config_value.strip_prefix("keyring:") {
142 let entry = keyring::Entry::new(SERVICE_NAME, ref_key)
143 .map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
144 entry.get_password().map_err(|e| match e {
145 keyring::Error::NoEntry => ConfigDecryptionError::KeyringError(format!(
146 "Keyring entry not found for '{ref_key}'."
147 )),
148 other => ConfigDecryptionError::KeyringError(other.to_string()),
149 })
150 } else if let Some(b64_data) = config_value.strip_prefix("enc:v2:") {
151 let data = B64
152 .decode(b64_data)
153 .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
154 self._aes_decrypt_v2(&data).map_err(|e| match e {
155 ConfigDecryptionError::AuthTagMismatch => ConfigDecryptionError::AuthTagMismatch,
156 other => ConfigDecryptionError::KeyringError(format!(
157 "Failed to decrypt configuration value '{key}'. \
158 Re-configure with 'apcore-cli config set {key}'. Cause: {other}"
159 )),
160 })
161 } else if let Some(b64_data) = config_value.strip_prefix("enc:") {
162 let data = B64
163 .decode(b64_data)
164 .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
165 self._aes_decrypt_v1(&data).map_err(|e| match e {
166 ConfigDecryptionError::AuthTagMismatch => ConfigDecryptionError::AuthTagMismatch,
167 other => ConfigDecryptionError::KeyringError(format!(
168 "Failed to decrypt configuration value '{key}'. \
169 Re-configure with 'apcore-cli config set {key}'. Cause: {other}"
170 )),
171 })
172 } else {
173 Ok(config_value.to_string())
174 }
175 }
176
177 fn _keyring_available(&self) -> bool {
183 if self._force_aes {
184 return false;
185 }
186 let entry = match keyring::Entry::new(SERVICE_NAME, "__apcore_probe__") {
187 Ok(e) => e,
188 Err(_) => return false,
189 };
190 matches!(entry.get_password(), Ok(_) | Err(keyring::Error::NoEntry))
191 }
192
193 fn _derive_key_with_salt(&self, salt: &[u8]) -> Result<[u8; 32], ConfigDecryptionError> {
199 self._derive_key_with_salt_iter(salt, PBKDF2_ITERATIONS)
200 }
201
202 fn _derive_key_with_salt_iter(
207 &self,
208 salt: &[u8],
209 iterations: u32,
210 ) -> Result<[u8; 32], ConfigDecryptionError> {
211 let material = if let Ok(passphrase) = std::env::var("APCORE_CLI_CONFIG_PASSPHRASE") {
212 if !passphrase.is_empty() {
213 passphrase
214 } else {
215 let hostname = gethostname()
216 .into_string()
217 .unwrap_or_else(|_| "unknown".to_string());
218 let username = std::env::var("USER")
219 .or_else(|_| std::env::var("LOGNAME"))
220 .unwrap_or_else(|_| "unknown".to_string());
221 format!("{hostname}:{username}")
222 }
223 } else {
224 let hostname = gethostname()
225 .into_string()
226 .unwrap_or_else(|_| "unknown".to_string());
227 let username = std::env::var("USER")
228 .or_else(|_| std::env::var("LOGNAME"))
229 .unwrap_or_else(|_| "unknown".to_string());
230 format!("{hostname}:{username}")
231 };
232 let mut key = [0u8; 32];
233 pbkdf2_hmac::<Sha256>(material.as_bytes(), salt, iterations, &mut key);
234 Ok(key)
235 }
236
237 pub(crate) fn _aes_encrypt_v2(
243 &self,
244 plaintext: &str,
245 ) -> Result<Vec<u8>, ConfigDecryptionError> {
246 let mut salt_bytes = [0u8; PBKDF2_SALT_LEN_V2];
247 OsRng.fill_bytes(&mut salt_bytes);
248 let raw_key = self._derive_key_with_salt(&salt_bytes)?;
249 let cipher = Aes256Gcm::new_from_slice(&raw_key)
250 .map_err(|e| ConfigDecryptionError::KdfError(e.to_string()))?;
251 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
252 let encrypted = cipher
253 .encrypt(&nonce, plaintext.as_bytes())
254 .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
255 let ct_len = encrypted.len() - 16;
256 let ciphertext = &encrypted[..ct_len];
257 let tag = &encrypted[ct_len..];
258 let mut out = Vec::with_capacity(PBKDF2_SALT_LEN_V2 + 12 + 16 + ct_len);
259 out.extend_from_slice(&salt_bytes);
260 out.extend_from_slice(nonce.as_slice());
261 out.extend_from_slice(tag);
262 out.extend_from_slice(ciphertext);
263 Ok(out)
264 }
265
266 pub(crate) fn _aes_decrypt_v2(&self, data: &[u8]) -> Result<String, ConfigDecryptionError> {
270 if data.len() < MIN_WIRE_LEN_V2 {
271 return Err(ConfigDecryptionError::AuthTagMismatch);
272 }
273 let salt = &data[..PBKDF2_SALT_LEN_V2];
274 let rest = &data[PBKDF2_SALT_LEN_V2..];
275 let raw_key = self._derive_key_with_salt(salt)?;
276 let cipher = Aes256Gcm::new_from_slice(&raw_key)
277 .map_err(|e| ConfigDecryptionError::KdfError(e.to_string()))?;
278 let nonce = Nonce::from_slice(&rest[..12]);
279 let tag = &rest[12..28];
280 let ciphertext = &rest[28..];
281 let mut ct_with_tag = Vec::with_capacity(ciphertext.len() + 16);
282 ct_with_tag.extend_from_slice(ciphertext);
283 ct_with_tag.extend_from_slice(tag);
284 let plaintext = cipher
285 .decrypt(nonce, ct_with_tag.as_slice())
286 .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
287 String::from_utf8(plaintext).map_err(|_| ConfigDecryptionError::InvalidUtf8)
288 }
289
290 pub(crate) fn _aes_decrypt_v1(&self, data: &[u8]) -> Result<String, ConfigDecryptionError> {
301 if data.len() < MIN_WIRE_LEN_V1 {
302 return Err(ConfigDecryptionError::AuthTagMismatch);
303 }
304 let nonce = Nonce::from_slice(&data[..12]);
305 let tag = &data[12..28];
306 let ciphertext = &data[28..];
307
308 let mut last_err: Option<ConfigDecryptionError> = None;
309 for iterations in [PBKDF2_ITERATIONS, 100_000_u32] {
310 let raw_key = match self._derive_key_with_salt_iter(PBKDF2_SALT_V1, iterations) {
311 Ok(k) => k,
312 Err(e) => {
313 last_err = Some(e);
314 continue;
315 }
316 };
317 let cipher = match Aes256Gcm::new_from_slice(&raw_key) {
318 Ok(c) => c,
319 Err(e) => {
320 last_err = Some(ConfigDecryptionError::KdfError(e.to_string()));
321 continue;
322 }
323 };
324 let mut ct_with_tag = Vec::with_capacity(ciphertext.len() + 16);
325 ct_with_tag.extend_from_slice(ciphertext);
326 ct_with_tag.extend_from_slice(tag);
327 match cipher.decrypt(nonce, ct_with_tag.as_slice()) {
328 Ok(plaintext) => {
329 return String::from_utf8(plaintext)
330 .map_err(|_| ConfigDecryptionError::InvalidUtf8);
331 }
332 Err(_) => {
333 last_err = Some(ConfigDecryptionError::AuthTagMismatch);
334 continue;
335 }
336 }
337 }
338 Err(last_err.unwrap_or(ConfigDecryptionError::AuthTagMismatch))
339 }
340}
341
342#[cfg(test)]
347mod tests {
348 use super::*;
349
350 fn aes_encryptor() -> ConfigEncryptor {
352 ConfigEncryptor { _force_aes: true }
353 }
354
355 #[test]
356 fn test_aes_v2_roundtrip() {
357 let enc = aes_encryptor();
358 let ciphertext = enc._aes_encrypt_v2("hello-secret").expect("encrypt");
359 let plaintext = enc._aes_decrypt_v2(&ciphertext).expect("decrypt");
360 assert_eq!(plaintext, "hello-secret");
361 }
362
363 fn _v1_encrypt_with_iterations(
367 enc: &ConfigEncryptor,
368 plaintext: &str,
369 iterations: u32,
370 ) -> Vec<u8> {
371 use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
372 let raw_key = enc
373 ._derive_key_with_salt_iter(PBKDF2_SALT_V1, iterations)
374 .expect("derive");
375 let cipher = Aes256Gcm::new_from_slice(&raw_key).expect("cipher");
376 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
377 let ct_with_tag = cipher
378 .encrypt(&nonce, plaintext.as_bytes())
379 .expect("encrypt");
380 assert!(ct_with_tag.len() >= 16);
384 let split = ct_with_tag.len() - 16;
385 let (ct, tag) = ct_with_tag.split_at(split);
386 let mut wire = Vec::with_capacity(12 + 16 + ct.len());
387 wire.extend_from_slice(&nonce);
388 wire.extend_from_slice(tag);
389 wire.extend_from_slice(ct);
390 wire
391 }
392
393 #[test]
394 fn test_aes_v1_decrypts_600k_ciphertext() {
395 let enc = aes_encryptor();
397 let wire = _v1_encrypt_with_iterations(&enc, "current-secret", PBKDF2_ITERATIONS);
398 let plaintext = enc._aes_decrypt_v1(&wire).expect("decrypt");
399 assert_eq!(plaintext, "current-secret");
400 }
401
402 #[test]
403 fn test_aes_v1_decrypts_100k_legacy_ciphertext() {
404 let enc = aes_encryptor();
408 let wire = _v1_encrypt_with_iterations(&enc, "legacy-secret", 100_000);
409 let plaintext = enc
410 ._aes_decrypt_v1(&wire)
411 .expect("v1 decrypt must retry at 100k iterations");
412 assert_eq!(plaintext, "legacy-secret");
413 }
414
415 #[test]
416 fn test_aes_v1_rejects_wrong_iterations() {
417 let enc = aes_encryptor();
420 let wire = _v1_encrypt_with_iterations(&enc, "weird", 200_000);
421 let result = enc._aes_decrypt_v1(&wire);
422 assert!(result.is_err(), "200k ciphertext must not decrypt");
423 }
424
425 #[test]
426 fn test_store_without_keyring_returns_enc_v2_prefix() {
427 let enc = aes_encryptor();
428 let token = enc.store("auth.api_key", "secret123").expect("store");
429 assert!(
430 token.starts_with("enc:v2:"),
431 "expected enc:v2: prefix, got {token}"
432 );
433 }
434
435 #[test]
436 fn test_retrieve_enc_v2_value() {
437 let enc = aes_encryptor();
438 let token = enc.store("auth.api_key", "secret123").expect("store");
439 let result = enc.retrieve(&token, "auth.api_key").expect("retrieve");
440 assert_eq!(result, "secret123");
441 }
442
443 #[test]
444 fn test_retrieve_plaintext_passthrough() {
445 let enc = aes_encryptor();
446 let result = enc.retrieve("plain-value", "some.key").expect("retrieve");
447 assert_eq!(result, "plain-value");
448 }
449
450 #[test]
451 fn test_retrieve_corrupted_v1_ciphertext_returns_error() {
452 let enc = aes_encryptor();
453 let mut bad = vec![0u8; 40];
454 bad[12] ^= 0xFF;
455 let config_value = format!("enc:{}", B64.encode(&bad));
456 let result = enc.retrieve(&config_value, "some.key");
457 assert!(matches!(
458 result,
459 Err(ConfigDecryptionError::AuthTagMismatch)
460 ));
461 }
462
463 #[test]
464 fn test_retrieve_corrupted_v2_ciphertext_returns_error() {
465 let enc = aes_encryptor();
466 let mut bad = vec![0u8; 56];
468 bad[16 + 12] ^= 0xFF;
469 let config_value = format!("enc:v2:{}", B64.encode(&bad));
470 let result = enc.retrieve(&config_value, "some.key");
471 assert!(matches!(
472 result,
473 Err(ConfigDecryptionError::AuthTagMismatch)
474 ));
475 }
476
477 #[test]
478 fn test_retrieve_short_v1_ciphertext_returns_error() {
479 let enc = aes_encryptor();
480 let config_value = format!("enc:{}", B64.encode([0u8; 10]));
481 let result = enc.retrieve(&config_value, "some.key");
482 assert!(matches!(
483 result,
484 Err(ConfigDecryptionError::AuthTagMismatch)
485 ));
486 }
487
488 #[test]
489 fn test_retrieve_short_v2_ciphertext_returns_error() {
490 let enc = aes_encryptor();
491 let config_value = format!("enc:v2:{}", B64.encode([0u8; 10]));
492 let result = enc.retrieve(&config_value, "some.key");
493 assert!(matches!(
494 result,
495 Err(ConfigDecryptionError::AuthTagMismatch)
496 ));
497 }
498
499 #[test]
500 fn test_derive_key_is_32_bytes() {
501 let enc = aes_encryptor();
502 let key = enc._derive_key_with_salt(PBKDF2_SALT_V1).expect("derive");
503 assert_eq!(key.len(), 32);
504 }
505
506 #[test]
507 fn test_v2_ciphertexts_differ_for_same_plaintext() {
508 let enc = aes_encryptor();
510 let ct1 = enc._aes_encrypt_v2("same").expect("e1");
511 let ct2 = enc._aes_encrypt_v2("same").expect("e2");
512 assert_ne!(ct1, ct2, "v2 ciphertexts must differ (random salt)");
513 }
514}