use super::paths::{ensure_config_dir, secrets_file_path};
use super::types::ConfigFile;
use crate::common::{AgentError, Result};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsFile {
#[serde(default)]
pub api: SecretsApiSection,
#[serde(default)]
pub providers: Vec<SecretsProviderEntry>,
#[serde(default)]
pub web: SecretsWebSection,
#[serde(default)]
pub telegram: SecretsTelegramSection,
#[serde(default)]
pub slack: SecretsSlackSection,
#[serde(default)]
pub discord: SecretsDiscordSection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsApiSection {
pub api_key_enc: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsProviderEntry {
pub name: String,
pub api_key_enc: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsWebSection {
pub username: Option<String>,
pub password_enc: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsTelegramSection {
pub token_enc: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsSlackSection {
pub bot_token_enc: Option<String>,
pub app_token_enc: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsDiscordSection {
pub token_enc: Option<String>,
}
pub fn load_secrets() -> SecretsFile {
let path = secrets_file_path();
if !path.exists() {
return SecretsFile::default();
}
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("warning: failed to read secrets file: {e}");
return SecretsFile::default();
}
};
match toml::from_str(&content) {
Ok(s) => s,
Err(e) => {
eprintln!("warning: secrets file is corrupted and will be ignored: {e}");
SecretsFile::default()
}
}
}
pub fn save_secrets(secrets: &SecretsFile) -> Result<()> {
let path = secrets_file_path();
let toml_str = toml::to_string_pretty(secrets)
.map_err(|e| AgentError::Config(format!("Failed to serialize secrets: {e}")))?;
let header = "# collet secrets — DO NOT COMMIT\n\
# Generated by collet. Add to .gitignore: .collet/.secrets\n\n";
let out = format!("{header}{toml_str}");
let tmp_path = path.with_extension("secrets.tmp");
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
.map_err(|e| AgentError::Config(format!("Failed to create temp secrets: {e}")))?;
f.write_all(out.as_bytes())
.map_err(|e| AgentError::Config(format!("Failed to write temp secrets: {e}")))?;
f.sync_all()
.map_err(|e| AgentError::Config(format!("Failed to sync temp secrets: {e}")))?;
}
#[cfg(not(unix))]
{
std::fs::write(&tmp_path, &out)
.map_err(|e| AgentError::Config(format!("Failed to write temp secrets: {e}")))?;
}
std::fs::rename(&tmp_path, &path)
.map_err(|e| AgentError::Config(format!("Failed to rename secrets file: {e}")))?;
Ok(())
}
pub fn extract_secrets(cf: &ConfigFile, existing_machine_id: Option<String>) -> SecretsFile {
SecretsFile {
api: SecretsApiSection {
api_key_enc: cf.api.api_key_enc.clone(),
},
providers: cf
.providers
.iter()
.filter(|p| p.api_key_enc.is_some())
.map(|p| SecretsProviderEntry {
name: p.name.clone(),
api_key_enc: p.api_key_enc.clone(),
})
.collect(),
web: SecretsWebSection {
username: cf.web.username.clone(),
password_enc: cf.web.password_enc.clone(),
},
telegram: SecretsTelegramSection {
token_enc: cf.telegram.token_enc.clone(),
},
slack: SecretsSlackSection {
bot_token_enc: cf.slack.bot_token_enc.clone(),
app_token_enc: cf.slack.app_token_enc.clone(),
},
discord: SecretsDiscordSection {
token_enc: cf.discord.token_enc.clone(),
},
machine_id: existing_machine_id,
}
}
pub(super) fn merge_secrets(cf: &mut ConfigFile, secrets: &SecretsFile) {
if secrets.api.api_key_enc.is_some() {
cf.api.api_key_enc = secrets.api.api_key_enc.clone();
}
for sp in &secrets.providers {
if let Some(pe) = cf.providers.iter_mut().find(|p| p.name == sp.name)
&& sp.api_key_enc.is_some()
{
pe.api_key_enc = sp.api_key_enc.clone();
}
}
if secrets.web.username.is_some() {
cf.web.username = secrets.web.username.clone();
}
if secrets.web.password_enc.is_some() {
cf.web.password_enc = secrets.web.password_enc.clone();
}
if secrets.telegram.token_enc.is_some() {
cf.telegram.token_enc = secrets.telegram.token_enc.clone();
}
if secrets.slack.bot_token_enc.is_some() {
cf.slack.bot_token_enc = secrets.slack.bot_token_enc.clone();
}
if secrets.slack.app_token_enc.is_some() {
cf.slack.app_token_enc = secrets.slack.app_token_enc.clone();
}
if secrets.discord.token_enc.is_some() {
cf.discord.token_enc = secrets.discord.token_enc.clone();
}
}
pub fn scan_env_to_secrets() -> SecretsFile {
let mut s = SecretsFile::default();
for var in &["COLLET_API_KEY"] {
if let Ok(val) = std::env::var(var)
&& !val.is_empty()
{
if let Ok(enc) = encrypt_key(&val) {
s.api.api_key_enc = Some(enc);
}
break;
}
}
const PROVIDER_VARS: &[(&str, &str)] = &[
("ANTHROPIC_API_KEY", "anthropic"),
("OPENAI_API_KEY", "openai"),
("GEMINI_API_KEY", "gemini"),
("GROQ_API_KEY", "groq"),
("MISTRAL_API_KEY", "mistral"),
];
for &(var, provider) in PROVIDER_VARS {
if let Ok(val) = std::env::var(var)
&& !val.is_empty()
&& let Ok(enc) = encrypt_key(&val)
{
if s.api.api_key_enc.is_none() {
s.api.api_key_enc = Some(enc.clone());
}
s.providers.push(SecretsProviderEntry {
name: provider.to_string(),
api_key_enc: Some(enc),
});
}
}
if let Ok(val) = std::env::var("COLLET_WEB_PASSWORD")
&& !val.is_empty()
&& let Ok(enc) = encrypt_key(&val)
{
s.web.password_enc = Some(enc);
}
if let Ok(val) = std::env::var("TELEGRAM_BOT_TOKEN")
&& !val.is_empty()
&& let Ok(enc) = encrypt_key(&val)
{
s.telegram.token_enc = Some(enc);
}
if let Ok(val) = std::env::var("SLACK_BOT_TOKEN")
&& !val.is_empty()
&& let Ok(enc) = encrypt_key(&val)
{
s.slack.bot_token_enc = Some(enc);
}
if let Ok(val) = std::env::var("SLACK_APP_TOKEN")
&& !val.is_empty()
&& let Ok(enc) = encrypt_key(&val)
{
s.slack.app_token_enc = Some(enc);
}
if let Ok(val) = std::env::var("DISCORD_BOT_TOKEN")
&& !val.is_empty()
&& let Ok(enc) = encrypt_key(&val)
{
s.discord.token_enc = Some(enc);
}
s
}
pub fn update_gitignore_for_secrets() {
let start = match std::env::current_dir() {
Ok(d) => d,
Err(_) => return,
};
let git_root = {
let mut dir = start.as_path();
loop {
if dir.join(".git").exists() {
break Some(dir.to_path_buf());
}
match dir.parent() {
Some(p) => dir = p,
None => break None,
}
}
};
let base = git_root.unwrap_or(start);
let gitignore_path = base.join(".gitignore");
let existing = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
const ENTRIES: &[&str] = &[".collet/.secrets", ".collet/*.secrets.tmp"];
let mut additions = String::new();
for entry in ENTRIES {
if !existing.lines().any(|l| l.trim() == *entry) {
additions.push_str(entry);
additions.push('\n');
}
}
if additions.is_empty() {
return;
}
let new_content = if existing.is_empty() {
format!("# collet\n{additions}")
} else {
format!("{}\n# collet\n{additions}", existing.trim_end())
};
let _ = std::fs::write(&gitignore_path, new_content);
}
fn resolve_hostname() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.or_else(|_| {
std::fs::read_to_string("/proc/sys/kernel/hostname").map(|s| s.trim().to_string())
})
.unwrap_or_else(|_| "collet-host".into())
}
fn derive_machine_key_with_salt(salt: &[u8]) -> [u8; 32] {
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "collet-user".into());
let hostname = resolve_hostname();
let mut hasher = blake3::Hasher::new();
hasher.update(salt);
hasher.update(b"collet:");
hasher.update(hostname.as_bytes());
hasher.update(b":");
hasher.update(username.as_bytes());
*hasher.finalize().as_bytes()
}
fn get_or_create_machine_id() -> [u8; 32] {
static MACHINE_ID: OnceLock<[u8; 32]> = OnceLock::new();
*MACHINE_ID.get_or_init(|| {
use base64::Engine;
let mut secrets = load_secrets();
if let Some(ref encoded) = secrets.machine_id {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded)
&& bytes.len() == 32
{
let mut salt = [0u8; 32];
salt.copy_from_slice(&bytes);
return salt;
}
eprintln!("warning: machine_id in secrets file is invalid; regenerating");
}
let salt: [u8; 32] = rand::random();
secrets.machine_id = Some(base64::engine::general_purpose::STANDARD.encode(salt));
if let Err(e) = save_secrets(&secrets) {
eprintln!("warning: could not persist machine_id to secrets file: {e}");
}
salt
})
}
pub fn encrypt_key(plaintext: &str) -> Result<String> {
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use base64::Engine;
let salt = get_or_create_machine_id();
let key_bytes = derive_machine_key_with_salt(&salt);
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.map_err(|e| AgentError::Config(format!("cipher init failed: {e}")))?;
let nonce_bytes: [u8; 12] = rand::random();
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| AgentError::Config(format!("encryption failed: {e}")))?;
let mut combined = Vec::with_capacity(12 + ciphertext.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(&ciphertext);
Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
}
pub fn decrypt_key(encoded: &str) -> Result<String> {
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use base64::Engine;
let combined = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| AgentError::Config(format!("Failed to decode base64 encrypted key: {}", e)))?;
if combined.len() < 13 {
return Err(AgentError::Config("Encrypted key too short".to_string()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let salt = get_or_create_machine_id();
let key_bytes = derive_machine_key_with_salt(&salt);
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.map_err(|e| AgentError::Config(format!("cipher init failed: {e}")))?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| {
AgentError::Config(
"Decryption failed — the stored credentials were encrypted with a different \
machine_id (legacy file or different machine). \
Please re-enter your credentials (`collet config --set-key`)."
.to_string(),
)
})?;
String::from_utf8(plaintext)
.map_err(|e| AgentError::Config(format!("Decrypted key is not valid UTF-8: {e}")))
}
pub fn save_encrypted_key(key: &str) -> Result<()> {
let encrypted = encrypt_key(key)?;
let decrypted = decrypt_key(&encrypted)?;
if decrypted != key {
return Err(AgentError::Config(
"Encryption verification failed: decrypted value does not match original.".to_string(),
));
}
let dir = ensure_config_dir()?;
let path = dir.join("config.toml");
let mut cf = if path.exists() {
let content = std::fs::read_to_string(&path)?;
toml::from_str::<ConfigFile>(&content).unwrap_or_default()
} else {
ConfigFile::default()
};
cf.api.api_key_enc = Some(encrypted);
cf.api.api_key = None;
let toml_str = toml::to_string_pretty(&cf)
.map_err(|e| AgentError::Config(format!("Failed to serialize config: {e}")))?;
let tmp_path = path.with_extension("toml.tmp");
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
.map_err(|e| {
AgentError::Config(format!(
"Failed to create temp config {}: {e}",
tmp_path.display()
))
})?;
f.write_all(toml_str.as_bytes()).map_err(|e| {
AgentError::Config(format!(
"Failed to write temp config {}: {e}",
tmp_path.display()
))
})?;
f.sync_all().map_err(|e| {
AgentError::Config(format!(
"Failed to sync temp config {}: {e}",
tmp_path.display()
))
})?;
}
#[cfg(not(unix))]
std::fs::write(&tmp_path, &toml_str).map_err(|e| {
AgentError::Config(format!(
"Failed to write temp config {}: {e}",
tmp_path.display()
))
})?;
std::fs::rename(&tmp_path, &path).map_err(|e| {
AgentError::Config(format!("Failed to replace config {}: {e}", path.display()))
})?;
eprintln!("✅ API key encrypted and saved.");
Ok(())
}
pub fn save_web_credentials(username: Option<String>, password: &str) -> Result<()> {
crate::config::wizard::ui::save_web_credentials(username, password)
}