use aes_gcm::{
aead::{rand_core::RngCore, Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use gethostname::gethostname;
use pbkdf2::pbkdf2_hmac;
use sha2::Sha256;
use thiserror::Error;
const SERVICE_NAME: &str = "apcore-cli";
const PBKDF2_SALT_V1: &[u8] = b"apcore-cli-config-v1";
const PBKDF2_ITERATIONS: u32 = 600_000;
const MIN_WIRE_LEN_V1: usize = 28;
const PBKDF2_SALT_LEN_V2: usize = 16;
const MIN_WIRE_LEN_V2: usize = PBKDF2_SALT_LEN_V2 + 28;
#[derive(Debug, Error)]
pub enum ConfigDecryptionError {
#[error("decryption failed: authentication tag mismatch or corrupt data")]
AuthTagMismatch,
#[error(
"Failed to decrypt configuration value '{key}'. \
Re-configure with 'apcore-cli config set {key}'."
)]
DecryptFailed { key: String },
#[error("decrypted data is not valid UTF-8")]
InvalidUtf8,
#[error("keyring error: {0}")]
KeyringError(String),
#[error("key derivation error: {0}")]
KdfError(String),
}
#[derive(Default)]
pub struct ConfigEncryptor {
_force_aes: bool,
}
impl ConfigEncryptor {
pub fn new() -> Result<Self, ConfigDecryptionError> {
Ok(Self::default())
}
#[cfg(any(test, feature = "test-support"))]
pub fn new_forced_aes() -> Self {
Self { _force_aes: true }
}
#[allow(dead_code)]
pub(crate) fn keyring_available(&self) -> bool {
self._keyring_available()
}
pub fn store(&self, key: &str, value: &str) -> Result<String, ConfigDecryptionError> {
if self._keyring_available() {
let entry = keyring::Entry::new(SERVICE_NAME, key)
.map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
entry
.set_password(value)
.map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
Ok(format!("keyring:{key}"))
} else {
tracing::warn!("OS keyring unavailable. Using file-based encryption.");
let ciphertext = self._aes_encrypt_v2(value)?;
Ok(format!("enc:v2:{}", B64.encode(&ciphertext)))
}
}
pub fn retrieve(&self, config_value: &str, key: &str) -> Result<String, ConfigDecryptionError> {
if let Some(ref_key) = config_value.strip_prefix("keyring:") {
let entry = keyring::Entry::new(SERVICE_NAME, ref_key)
.map_err(|e| ConfigDecryptionError::KeyringError(e.to_string()))?;
entry.get_password().map_err(|e| match e {
keyring::Error::NoEntry => ConfigDecryptionError::KeyringError(format!(
"Keyring entry not found for '{ref_key}'."
)),
other => ConfigDecryptionError::KeyringError(other.to_string()),
})
} else if let Some(b64_data) = config_value.strip_prefix("enc:v2:") {
let data = B64
.decode(b64_data)
.map_err(|_| ConfigDecryptionError::DecryptFailed {
key: key.to_string(),
})?;
self._aes_decrypt_v2(&data)
.map_err(|_| ConfigDecryptionError::DecryptFailed {
key: key.to_string(),
})
} else if let Some(b64_data) = config_value.strip_prefix("enc:") {
let data = B64
.decode(b64_data)
.map_err(|_| ConfigDecryptionError::DecryptFailed {
key: key.to_string(),
})?;
self._aes_decrypt_v1(&data)
.map_err(|_| ConfigDecryptionError::DecryptFailed {
key: key.to_string(),
})
} else {
Ok(config_value.to_string())
}
}
fn _keyring_available(&self) -> bool {
if self._force_aes {
return false;
}
let entry = match keyring::Entry::new(SERVICE_NAME, "__apcore_probe__") {
Ok(e) => e,
Err(_) => return false,
};
matches!(entry.get_password(), Ok(_) | Err(keyring::Error::NoEntry))
}
fn _derive_key_with_salt(&self, salt: &[u8]) -> Result<[u8; 32], ConfigDecryptionError> {
self._derive_key_with_salt_iter(salt, PBKDF2_ITERATIONS)
}
fn _derive_key_with_salt_iter(
&self,
salt: &[u8],
iterations: u32,
) -> Result<[u8; 32], ConfigDecryptionError> {
let material = if let Ok(passphrase) = std::env::var("APCORE_CLI_CONFIG_PASSPHRASE") {
if !passphrase.is_empty() {
passphrase
} else {
let hostname = gethostname()
.into_string()
.unwrap_or_else(|_| "unknown".to_string());
let username = Self::resolve_username_from_env();
format!("{hostname}:{username}")
}
} else {
let hostname = gethostname()
.into_string()
.unwrap_or_else(|_| "unknown".to_string());
let username = Self::resolve_username_from_env();
format!("{hostname}:{username}")
};
let mut key = [0u8; 32];
pbkdf2_hmac::<Sha256>(material.as_bytes(), salt, iterations, &mut key);
Ok(key)
}
fn resolve_username_from_env() -> String {
Self::resolve_username_from_env_with(&|k| std::env::var(k).ok())
}
fn resolve_username_from_env_with<F>(env_lookup: &F) -> String
where
F: Fn(&str) -> Option<String>,
{
for key in ["USER", "LOGNAME", "USERNAME"] {
if let Some(v) = env_lookup(key) {
if !v.is_empty() {
return v;
}
}
}
"unknown".to_string()
}
pub(crate) fn _aes_encrypt_v2(
&self,
plaintext: &str,
) -> Result<Vec<u8>, ConfigDecryptionError> {
let mut salt_bytes = [0u8; PBKDF2_SALT_LEN_V2];
OsRng.fill_bytes(&mut salt_bytes);
let raw_key = self._derive_key_with_salt(&salt_bytes)?;
let cipher = Aes256Gcm::new_from_slice(&raw_key)
.map_err(|e| ConfigDecryptionError::KdfError(e.to_string()))?;
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let encrypted = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
let ct_len = encrypted.len() - 16;
let ciphertext = &encrypted[..ct_len];
let tag = &encrypted[ct_len..];
let mut out = Vec::with_capacity(PBKDF2_SALT_LEN_V2 + 12 + 16 + ct_len);
out.extend_from_slice(&salt_bytes);
out.extend_from_slice(nonce.as_slice());
out.extend_from_slice(tag);
out.extend_from_slice(ciphertext);
Ok(out)
}
pub(crate) fn _aes_decrypt_v2(&self, data: &[u8]) -> Result<String, ConfigDecryptionError> {
if data.len() < MIN_WIRE_LEN_V2 {
return Err(ConfigDecryptionError::AuthTagMismatch);
}
let salt = &data[..PBKDF2_SALT_LEN_V2];
let rest = &data[PBKDF2_SALT_LEN_V2..];
let raw_key = self._derive_key_with_salt(salt)?;
let cipher = Aes256Gcm::new_from_slice(&raw_key)
.map_err(|e| ConfigDecryptionError::KdfError(e.to_string()))?;
let nonce = Nonce::from_slice(&rest[..12]);
let tag = &rest[12..28];
let ciphertext = &rest[28..];
let mut ct_with_tag = Vec::with_capacity(ciphertext.len() + 16);
ct_with_tag.extend_from_slice(ciphertext);
ct_with_tag.extend_from_slice(tag);
let plaintext = cipher
.decrypt(nonce, ct_with_tag.as_slice())
.map_err(|_| ConfigDecryptionError::AuthTagMismatch)?;
String::from_utf8(plaintext).map_err(|_| ConfigDecryptionError::InvalidUtf8)
}
pub(crate) fn _aes_decrypt_v1(&self, data: &[u8]) -> Result<String, ConfigDecryptionError> {
if data.len() < MIN_WIRE_LEN_V1 {
return Err(ConfigDecryptionError::AuthTagMismatch);
}
let nonce = Nonce::from_slice(&data[..12]);
let tag = &data[12..28];
let ciphertext = &data[28..];
let mut last_err: Option<ConfigDecryptionError> = None;
for iterations in [PBKDF2_ITERATIONS, 100_000_u32] {
let raw_key = match self._derive_key_with_salt_iter(PBKDF2_SALT_V1, iterations) {
Ok(k) => k,
Err(e) => {
last_err = Some(e);
continue;
}
};
let cipher = match Aes256Gcm::new_from_slice(&raw_key) {
Ok(c) => c,
Err(e) => {
last_err = Some(ConfigDecryptionError::KdfError(e.to_string()));
continue;
}
};
let mut ct_with_tag = Vec::with_capacity(ciphertext.len() + 16);
ct_with_tag.extend_from_slice(ciphertext);
ct_with_tag.extend_from_slice(tag);
match cipher.decrypt(nonce, ct_with_tag.as_slice()) {
Ok(plaintext) => {
return String::from_utf8(plaintext)
.map_err(|_| ConfigDecryptionError::InvalidUtf8);
}
Err(_) => {
last_err = Some(ConfigDecryptionError::AuthTagMismatch);
continue;
}
}
}
Err(last_err.unwrap_or(ConfigDecryptionError::AuthTagMismatch))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn aes_encryptor() -> ConfigEncryptor {
ConfigEncryptor { _force_aes: true }
}
#[test]
fn test_resolve_username_walks_user_logname_username_chain() {
let env = |k: &str| -> Option<String> {
match k {
"USER" => Some("user_val".to_string()),
"LOGNAME" => Some("logname_val".to_string()),
"USERNAME" => Some("username_val".to_string()),
_ => None,
}
};
assert_eq!(
ConfigEncryptor::resolve_username_from_env_with(&env),
"user_val"
);
let env = |k: &str| -> Option<String> {
match k {
"LOGNAME" => Some("logname_val".to_string()),
"USERNAME" => Some("username_val".to_string()),
_ => None,
}
};
assert_eq!(
ConfigEncryptor::resolve_username_from_env_with(&env),
"logname_val"
);
let env = |k: &str| -> Option<String> {
match k {
"USERNAME" => Some("username_val".to_string()),
_ => None,
}
};
assert_eq!(
ConfigEncryptor::resolve_username_from_env_with(&env),
"username_val"
);
let env = |_: &str| -> Option<String> { None };
assert_eq!(
ConfigEncryptor::resolve_username_from_env_with(&env),
"unknown"
);
let env = |k: &str| -> Option<String> {
match k {
"USER" => Some(String::new()),
"LOGNAME" => Some(String::new()),
"USERNAME" => Some("windows_account".to_string()),
_ => None,
}
};
assert_eq!(
ConfigEncryptor::resolve_username_from_env_with(&env),
"windows_account"
);
}
#[test]
fn test_aes_v2_roundtrip() {
let enc = aes_encryptor();
let ciphertext = enc._aes_encrypt_v2("hello-secret").expect("encrypt");
let plaintext = enc._aes_decrypt_v2(&ciphertext).expect("decrypt");
assert_eq!(plaintext, "hello-secret");
}
fn _v1_encrypt_with_iterations(
enc: &ConfigEncryptor,
plaintext: &str,
iterations: u32,
) -> Vec<u8> {
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
let raw_key = enc
._derive_key_with_salt_iter(PBKDF2_SALT_V1, iterations)
.expect("derive");
let cipher = Aes256Gcm::new_from_slice(&raw_key).expect("cipher");
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ct_with_tag = cipher
.encrypt(&nonce, plaintext.as_bytes())
.expect("encrypt");
assert!(ct_with_tag.len() >= 16);
let split = ct_with_tag.len() - 16;
let (ct, tag) = ct_with_tag.split_at(split);
let mut wire = Vec::with_capacity(12 + 16 + ct.len());
wire.extend_from_slice(&nonce);
wire.extend_from_slice(tag);
wire.extend_from_slice(ct);
wire
}
#[test]
fn test_aes_v1_decrypts_600k_ciphertext() {
let enc = aes_encryptor();
let wire = _v1_encrypt_with_iterations(&enc, "current-secret", PBKDF2_ITERATIONS);
let plaintext = enc._aes_decrypt_v1(&wire).expect("decrypt");
assert_eq!(plaintext, "current-secret");
}
#[test]
fn test_aes_v1_decrypts_100k_legacy_ciphertext() {
let enc = aes_encryptor();
let wire = _v1_encrypt_with_iterations(&enc, "legacy-secret", 100_000);
let plaintext = enc
._aes_decrypt_v1(&wire)
.expect("v1 decrypt must retry at 100k iterations");
assert_eq!(plaintext, "legacy-secret");
}
#[test]
fn test_aes_v1_rejects_wrong_iterations() {
let enc = aes_encryptor();
let wire = _v1_encrypt_with_iterations(&enc, "weird", 200_000);
let result = enc._aes_decrypt_v1(&wire);
assert!(result.is_err(), "200k ciphertext must not decrypt");
}
#[test]
fn test_store_without_keyring_returns_enc_v2_prefix() {
let enc = aes_encryptor();
let token = enc.store("auth.api_key", "secret123").expect("store");
assert!(
token.starts_with("enc:v2:"),
"expected enc:v2: prefix, got {token}"
);
}
#[test]
fn test_retrieve_enc_v2_value() {
let enc = aes_encryptor();
let token = enc.store("auth.api_key", "secret123").expect("store");
let result = enc.retrieve(&token, "auth.api_key").expect("retrieve");
assert_eq!(result, "secret123");
}
#[test]
fn test_retrieve_plaintext_passthrough() {
let enc = aes_encryptor();
let result = enc.retrieve("plain-value", "some.key").expect("retrieve");
assert_eq!(result, "plain-value");
}
#[test]
fn test_retrieve_corrupted_v1_ciphertext_returns_error() {
let enc = aes_encryptor();
let mut bad = vec![0u8; 40];
bad[12] ^= 0xFF;
let config_value = format!("enc:{}", B64.encode(&bad));
let result = enc.retrieve(&config_value, "some.key");
assert!(matches!(
result,
Err(ConfigDecryptionError::DecryptFailed { ref key }) if key == "some.key"
));
}
#[test]
fn test_retrieve_corrupted_v2_ciphertext_returns_error() {
let enc = aes_encryptor();
let mut bad = vec![0u8; 56];
bad[16 + 12] ^= 0xFF;
let config_value = format!("enc:v2:{}", B64.encode(&bad));
let result = enc.retrieve(&config_value, "some.key");
assert!(matches!(
result,
Err(ConfigDecryptionError::DecryptFailed { ref key }) if key == "some.key"
));
}
#[test]
fn test_retrieve_short_v1_ciphertext_returns_error() {
let enc = aes_encryptor();
let config_value = format!("enc:{}", B64.encode([0u8; 10]));
let result = enc.retrieve(&config_value, "some.key");
assert!(matches!(
result,
Err(ConfigDecryptionError::DecryptFailed { ref key }) if key == "some.key"
));
}
#[test]
fn test_retrieve_short_v2_ciphertext_returns_error() {
let enc = aes_encryptor();
let config_value = format!("enc:v2:{}", B64.encode([0u8; 10]));
let result = enc.retrieve(&config_value, "some.key");
assert!(matches!(
result,
Err(ConfigDecryptionError::DecryptFailed { ref key }) if key == "some.key"
));
}
#[test]
fn test_retrieve_decrypt_error_message_includes_key_and_remediation() {
let enc = aes_encryptor();
let mut bad = vec![0u8; 56];
bad[16 + 12] ^= 0xFF;
let config_value = format!("enc:v2:{}", B64.encode(&bad));
let err = enc
.retrieve(&config_value, "auth.api_key")
.expect_err("corrupt ciphertext should fail");
let msg = err.to_string();
assert!(
msg.contains("auth.api_key"),
"error message must name the config key, got: {msg}"
);
assert!(
msg.contains("apcore-cli config set auth.api_key"),
"error message must include remediation guidance, got: {msg}"
);
}
#[test]
fn test_retrieve_invalid_b64_returns_decrypt_failed_with_key() {
let enc = aes_encryptor();
let result = enc.retrieve("enc:v2:!!!not-base64!!!", "auth.api_key");
assert!(matches!(
result,
Err(ConfigDecryptionError::DecryptFailed { ref key }) if key == "auth.api_key"
));
}
#[test]
fn test_derive_key_is_32_bytes() {
let enc = aes_encryptor();
let key = enc._derive_key_with_salt(PBKDF2_SALT_V1).expect("derive");
assert_eq!(key.len(), 32);
}
#[test]
fn test_v2_ciphertexts_differ_for_same_plaintext() {
let enc = aes_encryptor();
let ct1 = enc._aes_encrypt_v2("same").expect("e1");
let ct2 = enc._aes_encrypt_v2("same").expect("e2");
assert_ne!(ct1, ct2, "v2 ciphertexts must differ (random salt)");
}
}