apcore_cli/security/
config_encryptor.rs1use aes_gcm::{
5 aead::{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: &[u8] = b"apcore-cli-config-v1";
20const PBKDF2_ITERATIONS: u32 = 100_000;
21const MIN_WIRE_LEN: usize = 28;
23
24#[derive(Debug, Error)]
30pub enum ConfigDecryptionError {
31 #[error("decryption failed: authentication tag mismatch or corrupt data")]
33 AuthTagMismatch,
34
35 #[error("decrypted data is not valid UTF-8")]
37 InvalidUtf8,
38
39 #[error("keyring error: {0}")]
41 KeyringError(String),
42
43 #[error("key derivation error: {0}")]
45 KdfError(String),
46}
47
48#[derive(Default)]
63pub struct ConfigEncryptor {
64 _force_aes: bool,
67}
68
69impl ConfigEncryptor {
70 pub fn new() -> Result<Self, ConfigDecryptionError> {
72 Ok(Self::default())
73 }
74
75 pub fn new_forced_aes() -> Self {
78 Self { _force_aes: true }
79 }
80
81 #[allow(dead_code)]
83 pub(crate) fn keyring_available(&self) -> bool {
84 self._keyring_available()
85 }
86
87 pub fn store(&self, key: &str, value: &str) -> Result<String, ConfigDecryptionError> {
109 if self._keyring_available() {
110 let entry = keyring::Entry::new(SERVICE_NAME, key)
111 .map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
112 entry
113 .set_password(value)
114 .map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
115 Ok(format!("keyring:{key}"))
116 } else {
117 tracing::warn!("OS keyring unavailable. Using file-based encryption.");
118 let ciphertext = self._aes_encrypt(value)?;
119 Ok(format!("enc:{}", B64.encode(&ciphertext)))
120 }
121 }
122
123 pub fn retrieve(&self, config_value: &str, key: &str) -> Result<String, ConfigDecryptionError> {
130 if let Some(ref_key) = config_value.strip_prefix("keyring:") {
131 let entry = keyring::Entry::new(SERVICE_NAME, ref_key)
132 .map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
133 entry.get_password().map_err(|e| match e {
134 keyring::Error::NoEntry => ConfigDecryptionError::KeyringError(format!(
135 "Keyring entry not found for '{ref_key}'."
136 )),
137 other => ConfigDecryptionError::KeyringError(other.to_string()),
138 })
139 } else if let Some(b64_data) = config_value.strip_prefix("enc:") {
140 let data = B64
141 .decode(b64_data)
142 .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
143 self._aes_decrypt(&data).map_err(|e| match e {
144 ConfigDecryptionError::AuthTagMismatch => ConfigDecryptionError::AuthTagMismatch,
145 other => ConfigDecryptionError::KeyringError(format!(
146 "Failed to decrypt configuration value '{key}'. \
147 Re-configure with 'apcore-cli config set {key}'. Cause: {other}"
148 )),
149 })
150 } else {
151 Ok(config_value.to_string())
152 }
153 }
154
155 fn _keyring_available(&self) -> bool {
161 if self._force_aes {
162 return false;
163 }
164 let entry = match keyring::Entry::new(SERVICE_NAME, "__apcore_probe__") {
165 Ok(e) => e,
166 Err(_) => return false,
167 };
168 matches!(entry.get_password(), Ok(_) | Err(keyring::Error::NoEntry))
169 }
170
171 fn _derive_key(&self) -> Result<[u8; 32], ConfigDecryptionError> {
183 let hostname = gethostname()
184 .into_string()
185 .unwrap_or_else(|_| "unknown".to_string());
186 let username = std::env::var("USER")
187 .or_else(|_| std::env::var("LOGNAME"))
188 .unwrap_or_else(|_| "unknown".to_string());
189 let material = format!("{hostname}:{username}");
190 let mut key = [0u8; 32];
191 pbkdf2_hmac::<Sha256>(
192 material.as_bytes(),
193 PBKDF2_SALT,
194 PBKDF2_ITERATIONS,
195 &mut key,
196 );
197 Ok(key)
198 }
199
200 pub(crate) fn _aes_encrypt(&self, plaintext: &str) -> Result<Vec<u8>, ConfigDecryptionError> {
204 let raw_key = self._derive_key()?;
205 let cipher = Aes256Gcm::new_from_slice(&raw_key)
206 .map_err(|e| ConfigDecryptionError::KdfError(e.to_string()))?;
207 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
208 let encrypted = cipher
210 .encrypt(&nonce, plaintext.as_bytes())
211 .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
212 let ct_len = encrypted.len() - 16;
214 let ciphertext = &encrypted[..ct_len];
215 let tag = &encrypted[ct_len..];
216 let mut out = Vec::with_capacity(12 + 16 + ct_len);
217 out.extend_from_slice(nonce.as_slice());
218 out.extend_from_slice(tag);
219 out.extend_from_slice(ciphertext);
220 Ok(out)
221 }
222
223 pub(crate) fn _aes_decrypt(&self, data: &[u8]) -> Result<String, ConfigDecryptionError> {
227 if data.len() < MIN_WIRE_LEN {
228 return Err(ConfigDecryptionError::AuthTagMismatch);
229 }
230 let raw_key = self._derive_key()?;
231 let cipher = Aes256Gcm::new_from_slice(&raw_key)
232 .map_err(|e| ConfigDecryptionError::KdfError(e.to_string()))?;
233 let nonce = Nonce::from_slice(&data[..12]);
234 let tag = &data[12..28];
235 let ciphertext = &data[28..];
236 let mut ct_with_tag = Vec::with_capacity(ciphertext.len() + 16);
238 ct_with_tag.extend_from_slice(ciphertext);
239 ct_with_tag.extend_from_slice(tag);
240 let plaintext = cipher
241 .decrypt(nonce, ct_with_tag.as_slice())
242 .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
243 String::from_utf8(plaintext).map_err(|_| ConfigDecryptionError::InvalidUtf8)
244 }
245}
246
247#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn aes_encryptor() -> ConfigEncryptor {
257 ConfigEncryptor { _force_aes: true }
258 }
259
260 #[test]
261 fn test_aes_roundtrip() {
262 let enc = aes_encryptor();
264 let ciphertext = enc._aes_encrypt("hello-secret").expect("encrypt");
265 let plaintext = enc._aes_decrypt(&ciphertext).expect("decrypt");
266 assert_eq!(plaintext, "hello-secret");
267 }
268
269 #[test]
270 fn test_store_without_keyring_returns_enc_prefix() {
271 let enc = aes_encryptor();
272 let token = enc.store("auth.api_key", "secret123").expect("store");
273 assert!(
274 token.starts_with("enc:"),
275 "expected enc: prefix, got {token}"
276 );
277 }
278
279 #[test]
280 fn test_retrieve_enc_value() {
281 let enc = aes_encryptor();
282 let token = enc.store("auth.api_key", "secret123").expect("store");
283 let result = enc.retrieve(&token, "auth.api_key").expect("retrieve");
284 assert_eq!(result, "secret123");
285 }
286
287 #[test]
288 fn test_retrieve_plaintext_passthrough() {
289 let enc = aes_encryptor();
290 let result = enc.retrieve("plain-value", "some.key").expect("retrieve");
291 assert_eq!(result, "plain-value");
292 }
293
294 #[test]
295 fn test_retrieve_corrupted_ciphertext_returns_error() {
296 let enc = aes_encryptor();
297 let mut bad = vec![0u8; 40];
299 bad[12] ^= 0xFF; let b64 = B64.encode(&bad);
301 let config_value = format!("enc:{b64}");
302 let result = enc.retrieve(&config_value, "some.key");
303 assert!(matches!(
304 result,
305 Err(ConfigDecryptionError::AuthTagMismatch)
306 ));
307 }
308
309 #[test]
310 fn test_retrieve_short_ciphertext_returns_error() {
311 let enc = aes_encryptor();
312 let b64 = B64.encode([0u8; 10]);
314 let config_value = format!("enc:{b64}");
315 let result = enc.retrieve(&config_value, "some.key");
316 assert!(matches!(
317 result,
318 Err(ConfigDecryptionError::AuthTagMismatch)
319 ));
320 }
321
322 #[test]
323 fn test_derive_key_is_32_bytes() {
324 let enc = aes_encryptor();
325 let key = enc._derive_key().expect("derive");
326 assert_eq!(key.len(), 32);
327 }
328
329 #[test]
330 fn test_nonces_are_unique() {
331 let enc = aes_encryptor();
333 let ct1 = enc._aes_encrypt("same").expect("e1");
334 let ct2 = enc._aes_encrypt("same").expect("e2");
335 assert_ne!(&ct1[..12], &ct2[..12], "nonces must differ");
336 }
337}