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::{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
14// ---------------------------------------------------------------------------
15// Constants
16// ---------------------------------------------------------------------------
17
18const SERVICE_NAME: &str = "apcore-cli";
19/// Legacy static salt used by `enc:` (v1) tokens — kept for decryption
20/// backward compatibility only.  New encryptions use `enc:v2:` with a
21/// per-encryption random salt embedded in the wire bytes.
22const PBKDF2_SALT_V1: &[u8] = b"apcore-cli-config-v1";
23/// OWASP 2026 minimum for PBKDF2-HMAC-SHA256.
24const PBKDF2_ITERATIONS: u32 = 600_000;
25/// Minimum v1 wire-format length: 12-byte nonce + 16-byte tag.
26const MIN_WIRE_LEN_V1: usize = 28;
27/// Random salt length prepended to v2 wire bytes.
28const PBKDF2_SALT_LEN_V2: usize = 16;
29/// Minimum v2 wire-format length: 16-byte salt + 12-byte nonce + 16-byte tag.
30const MIN_WIRE_LEN_V2: usize = PBKDF2_SALT_LEN_V2 + 28;
31
32// ---------------------------------------------------------------------------
33// ConfigDecryptionError
34// ---------------------------------------------------------------------------
35
36/// Errors produced by decryption or key-derivation operations.
37#[derive(Debug, Error)]
38pub enum ConfigDecryptionError {
39    /// The ciphertext is malformed or has been tampered with.
40    #[error("decryption failed: authentication tag mismatch or corrupt data")]
41    AuthTagMismatch,
42
43    /// The stored data was not valid UTF-8 after decryption.
44    #[error("decrypted data is not valid UTF-8")]
45    InvalidUtf8,
46
47    /// Keyring access failed.
48    #[error("keyring error: {0}")]
49    KeyringError(String),
50
51    /// Key-derivation failed.
52    #[error("key derivation error: {0}")]
53    KdfError(String),
54}
55
56// ---------------------------------------------------------------------------
57// ConfigEncryptor
58// ---------------------------------------------------------------------------
59
60/// AES-GCM encrypted config store backed by the system keyring.
61///
62/// Uses PBKDF2-HMAC-SHA256 for key derivation from a machine-specific
63/// `hostname:username` material, and AES-256-GCM for authenticated encryption.
64///
65/// Wire format for AES-encrypted values:
66///   `enc:<base64(nonce[12] || tag[16] || ciphertext)>`
67///
68/// Keyring-stored values are referenced as:
69///   `keyring:<key>`
70#[derive(Default)]
71pub struct ConfigEncryptor {
72    /// When `true`, skip the OS keyring probe and always use AES encryption.
73    /// Intended for unit tests running in headless/CI environments.
74    _force_aes: bool,
75}
76
77impl ConfigEncryptor {
78    /// Create a new `ConfigEncryptor` using the OS keyring when available.
79    pub fn new() -> Result<Self, ConfigDecryptionError> {
80        Ok(Self::default())
81    }
82
83    /// Create a `ConfigEncryptor` that always uses AES encryption, bypassing
84    /// the OS keyring. Intended for use in tests running in headless/CI environments.
85    /// Gated behind the `test-support` feature so it is excluded from production builds.
86    #[cfg(any(test, feature = "test-support"))]
87    pub fn new_forced_aes() -> Self {
88        Self { _force_aes: true }
89    }
90
91    /// Wrapper for `_keyring_available()` for use in integration tests.
92    #[allow(dead_code)]
93    pub(crate) fn keyring_available(&self) -> bool {
94        self._keyring_available()
95    }
96
97    // -----------------------------------------------------------------------
98    // Public API
99    // -----------------------------------------------------------------------
100
101    /// Persist `value` for `key`.
102    ///
103    /// Tries the OS keyring first. On failure (headless / CI) falls back to
104    /// AES-256-GCM file encryption.
105    ///
106    /// Returns a config-file token:
107    /// - `"keyring:<key>"` when stored in the OS keyring.
108    /// - `"enc:<base64>"` when stored as an encrypted blob.
109    ///
110    /// # Security note
111    ///
112    /// The `enc:` fallback path derives its encryption key from the machine's
113    /// hostname and the current username. This protects against casual file
114    /// browsing but **not** against targeted attacks by co-tenants on shared
115    /// systems who know both values. For sensitive credentials (API keys,
116    /// tokens), prefer the `keyring:` path (OS keyring) when available, or
117    /// use environment variables instead of config file storage.
118    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    /// Retrieve the plaintext for a config value token.
134    ///
135    /// Handles four formats:
136    /// - `"keyring:<ref>"` — fetch from OS keyring.
137    /// - `"enc:v2:<base64>"` — v2: per-encryption random salt (PBKDF2 600k rounds).
138    /// - `"enc:<base64>"` — v1 legacy: static PBKDF2 salt (100k rounds, read-only).
139    /// - anything else — return as-is (plain passthrough).
140    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    // -----------------------------------------------------------------------
178    // Internal helpers
179    // -----------------------------------------------------------------------
180
181    /// Returns `true` when the OS keyring is accessible.
182    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    /// Derive a 32-byte AES key via PBKDF2-HMAC-SHA256.
194    ///
195    /// Key material precedence (matching Python/TS parity):
196    /// 1. `APCORE_CLI_CONFIG_PASSPHRASE` env var if set and non-empty.
197    /// 2. `hostname:username` fallback.
198    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    /// Like [`_derive_key_with_salt`] but with a configurable iteration count.
203    /// Used by [`_aes_decrypt_v1`] to support the 600k → 100k retry that
204    /// Python and TypeScript SDKs perform for early-version v1 ciphertexts
205    /// (D10-001).
206    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    /// Encrypt `plaintext` and return v2 wire bytes.
238    ///
239    /// Wire format: `salt[16] || nonce[12] || tag[16] || ciphertext`.
240    /// A 16-byte random salt is generated per encryption; it is embedded in
241    /// the output so no external state is required for decryption.
242    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    /// Decrypt v2 wire bytes back to a UTF-8 string.
267    ///
268    /// Expected wire format: `salt[16] || nonce[12] || tag[16] || ciphertext`.
269    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    /// Decrypt v1 (legacy) wire bytes back to a UTF-8 string.
291    ///
292    /// Expected wire format: `nonce[12] || tag[16] || ciphertext` with the
293    /// static `PBKDF2_SALT_V1` salt. Read-only — new encryptions use
294    /// `_aes_encrypt_v2` / `enc:v2:` tokens instead.
295    ///
296    /// Iteration retry per the cross-SDK contract (D10-001): tries 600k
297    /// (current Rust-written v1) first, then 100k (early Python/TS-written
298    /// v1). Mirrors apcore-cli-python/src/apcore_cli/security/config_encryptor.py:139
299    /// and apcore-cli-typescript/src/security/config-encryptor.ts:197.
300    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// ---------------------------------------------------------------------------
343// Unit tests
344// ---------------------------------------------------------------------------
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    /// Build an encryptor that always uses the AES path (keyring skipped).
351    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    /// Helper: encrypt v1 wire bytes with an explicit iteration count so we
364    /// can test the 600k → 100k decrypt fallback (D10-001) without exposing
365    /// the legacy 100k path as a public encryption API.
366    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        // Wire format: nonce[12] || tag[16] || ciphertext.
381        // aes-gcm appends the 16-byte tag to the end of the ciphertext, so we
382        // need to splice it into the middle slot.
383        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        // Sanity: the current iteration count round-trips.
396        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        // D10-001: very early SDK builds wrote v1 ciphertexts with 100k
405        // PBKDF2 iterations. Python and TS retry with 100k after 600k fails;
406        // Rust must do the same so legacy values remain readable.
407        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        // Ciphertext encrypted with 200k (neither 600k nor 100k) must fail
418        // — proves the retry list is bounded, not "try anything".
419        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        // v2 wire: 16 salt + 40 (12 nonce + 16 tag + 12 ct), corrupt tag.
467        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        // Random per-encryption salt means same plaintext produces different tokens.
509        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}