Skip to main content

apcore_cli/security/
config_encryptor.rs

1// apcore-cli — Encrypted config storage.
2// Protocol spec: SEC-03 (ConfigEncryptor, ConfigDecryptionError)
3
4use 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
14// ---------------------------------------------------------------------------
15// Constants
16// ---------------------------------------------------------------------------
17
18const SERVICE_NAME: &str = "apcore-cli";
19const PBKDF2_SALT: &[u8] = b"apcore-cli-config-v1";
20const PBKDF2_ITERATIONS: u32 = 100_000;
21/// Minimum wire-format length: 12-byte nonce + 16-byte tag.
22const MIN_WIRE_LEN: usize = 28;
23
24// ---------------------------------------------------------------------------
25// ConfigDecryptionError
26// ---------------------------------------------------------------------------
27
28/// Errors produced by decryption or key-derivation operations.
29#[derive(Debug, Error)]
30pub enum ConfigDecryptionError {
31    /// The ciphertext is malformed or has been tampered with.
32    #[error("decryption failed: authentication tag mismatch or corrupt data")]
33    AuthTagMismatch,
34
35    /// The stored data was not valid UTF-8 after decryption.
36    #[error("decrypted data is not valid UTF-8")]
37    InvalidUtf8,
38
39    /// Keyring access failed.
40    #[error("keyring error: {0}")]
41    KeyringError(String),
42
43    /// Key-derivation failed.
44    #[error("key derivation error: {0}")]
45    KdfError(String),
46}
47
48// ---------------------------------------------------------------------------
49// ConfigEncryptor
50// ---------------------------------------------------------------------------
51
52/// AES-GCM encrypted config store backed by the system keyring.
53///
54/// Uses PBKDF2-HMAC-SHA256 for key derivation from a machine-specific
55/// `hostname:username` material, and AES-256-GCM for authenticated encryption.
56///
57/// Wire format for AES-encrypted values:
58///   `enc:<base64(nonce[12] || tag[16] || ciphertext)>`
59///
60/// Keyring-stored values are referenced as:
61///   `keyring:<key>`
62#[derive(Default)]
63pub struct ConfigEncryptor {
64    /// When `true`, skip the OS keyring probe and always use AES encryption.
65    /// Intended for unit tests running in headless/CI environments.
66    _force_aes: bool,
67}
68
69impl ConfigEncryptor {
70    /// Create a new `ConfigEncryptor` using the OS keyring when available.
71    pub fn new() -> Result<Self, ConfigDecryptionError> {
72        Ok(Self::default())
73    }
74
75    /// Create a `ConfigEncryptor` that always uses AES encryption, bypassing
76    /// the OS keyring. Intended for use in tests running in headless/CI environments.
77    pub fn new_forced_aes() -> Self {
78        Self { _force_aes: true }
79    }
80
81    /// Wrapper for `_keyring_available()` for use in integration tests.
82    #[allow(dead_code)]
83    pub(crate) fn keyring_available(&self) -> bool {
84        self._keyring_available()
85    }
86
87    // -----------------------------------------------------------------------
88    // Public API
89    // -----------------------------------------------------------------------
90
91    /// Persist `value` for `key`.
92    ///
93    /// Tries the OS keyring first. On failure (headless / CI) falls back to
94    /// AES-256-GCM file encryption.
95    ///
96    /// Returns a config-file token:
97    /// - `"keyring:<key>"` when stored in the OS keyring.
98    /// - `"enc:<base64>"` when stored as an encrypted blob.
99    ///
100    /// # Security note
101    ///
102    /// The `enc:` fallback path derives its encryption key from the machine's
103    /// hostname and the current username. This protects against casual file
104    /// browsing but **not** against targeted attacks by co-tenants on shared
105    /// systems who know both values. For sensitive credentials (API keys,
106    /// tokens), prefer the `keyring:` path (OS keyring) when available, or
107    /// use environment variables instead of config file storage.
108    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    /// Retrieve the plaintext for a config value token.
124    ///
125    /// Handles three formats:
126    /// - `"keyring:<ref>"` — fetch from OS keyring.
127    /// - `"enc:<base64>"` — base64-decode then AES-GCM decrypt.
128    /// - anything else — return as-is (plain passthrough).
129    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    // -----------------------------------------------------------------------
156    // Internal helpers
157    // -----------------------------------------------------------------------
158
159    /// Returns `true` when the OS keyring is accessible.
160    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    /// Derive a 32-byte AES key via PBKDF2-HMAC-SHA256.
172    ///
173    /// Material: `"<hostname>:<username>"`.
174    /// Salt:     `b"apcore-cli-config-v1"`.
175    /// Rounds:   100 000.
176    ///
177    /// **Design note:** The key material is intentionally low-entropy
178    /// (hostname + username with a static salt) to match the Python reference
179    /// implementation. This provides protection against casual file access but
180    /// not against a targeted attacker who knows the hostname and username.
181    /// For stronger protection, use the OS keyring path (`keyring:` prefix).
182    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    /// Encrypt `plaintext` and return the raw wire bytes.
201    ///
202    /// Wire format: `nonce[12] || tag[16] || ciphertext`.
203    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        // aes_gcm returns ciphertext || tag (tag is the last 16 bytes).
209        let encrypted = cipher
210            .encrypt(&nonce, plaintext.as_bytes())
211            .map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
212        // Reorder to wire format: nonce || tag || ciphertext.
213        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    /// Decrypt raw wire bytes back to a UTF-8 string.
224    ///
225    /// Expected wire format: `nonce[12] || tag[16] || ciphertext`.
226    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        // aes_gcm::decrypt expects ciphertext || tag.
237        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// ---------------------------------------------------------------------------
248// Unit tests
249// ---------------------------------------------------------------------------
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// Build an encryptor that always uses the AES path (keyring skipped).
256    fn aes_encryptor() -> ConfigEncryptor {
257        ConfigEncryptor { _force_aes: true }
258    }
259
260    #[test]
261    fn test_aes_roundtrip() {
262        // Encrypt then decrypt must recover the original plaintext.
263        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        // 28 bytes minimum: 12 nonce + 16 tag; pad with zeroes then corrupt tag.
298        let mut bad = vec![0u8; 40];
299        bad[12] ^= 0xFF; // corrupt tag byte
300        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        // Fewer than 28 bytes — missing nonce+tag.
313        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        // Each encrypt call must produce a different nonce (probabilistically).
332        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}