use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::SaltString;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use chrono::Utc;
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct VaultFile {
pub secrets: HashMap<String, SecretEntry>,
}
fn default_project() -> String {
"global".to_string()
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SecretEntry {
pub ciphertext: String,
pub nonce: String,
pub salt: String,
pub updated_at: String,
pub updated_by: String,
pub version: u32,
#[serde(default = "default_project")]
pub project: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SecretItem {
pub name: String,
pub value: String,
pub updated_at: String,
pub updated_by: String,
pub version: u32,
pub age_days: i64,
pub project: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VaultStatus {
pub exists: bool,
pub is_unlocked: bool,
pub secret_count: usize,
pub environments: Vec<String>,
pub active_env: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnvMatrix {
pub keys: Vec<String>,
pub environments: Vec<String>,
pub presence: HashMap<String, HashMap<String, bool>>, pub key_projects: HashMap<String, String>, }
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct UnlockedSession {
pub passphrase: Vec<u8>,
}
impl UnlockedSession {
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.passphrase).unwrap_or_default()
}
}
pub struct VaultState {
pub active_session: Mutex<Option<UnlockedSession>>,
pub active_env: Mutex<String>,
pub last_active: Mutex<std::time::Instant>,
pub active_vault_dir: Mutex<PathBuf>,
}
impl Default for VaultState {
fn default() -> Self {
Self {
active_session: Mutex::new(None),
active_env: Mutex::new("dev".to_string()),
last_active: Mutex::new(std::time::Instant::now()),
active_vault_dir: Mutex::new(crate::profiles::get_active_vault_dir()),
}
}
}
pub fn ensure_unlocked(state: &VaultState) -> Result<(), String> {
let guard = state.active_session.lock().unwrap();
if guard.is_none() {
return Err("Sicherheits-Lock aktiv: Master-Passwort erforderlich!".to_string());
}
*state.last_active.lock().unwrap() = std::time::Instant::now();
Ok(())
}
pub fn get_forge_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".sb-forge")
} else {
PathBuf::from(".sb-forge")
}
}
pub fn vault_path(env: &str) -> PathBuf {
crate::profiles::get_active_vault_dir().join(format!("{}.vault", env))
}
pub fn load_vault_file(env: &str) -> VaultFile {
let path = vault_path(env);
if !path.exists() {
return VaultFile::default();
}
let content = std::fs::read_to_string(&path).unwrap_or_default();
toml::from_str(&content).unwrap_or_default()
}
pub fn save_vault_file(env: &str, vault: &VaultFile) -> Result<(), String> {
let dir = crate::profiles::get_active_vault_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = vault_path(env);
let content = toml::to_string_pretty(vault).map_err(|e| e.to_string())?;
std::fs::write(&path, content).map_err(|e| e.to_string())?;
Ok(())
}
pub fn list_available_environments() -> Vec<String> {
let dir = crate::profiles::get_active_vault_dir();
let mut envs = vec!["dev".to_string(), "staging".to_string(), "prod".to_string()];
if dir.exists() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("vault") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if !envs.contains(&stem.to_string()) {
envs.push(stem.to_string());
}
}
}
}
}
}
envs
}
pub fn decrypt_secret_entry(entry: &SecretEntry, passphrase: &str) -> Result<String, String> {
let salt = SaltString::from_b64(&entry.salt)
.map_err(|e| format!("Ungültiger Salt: {}", e))?;
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(passphrase.as_bytes(), &salt)
.map_err(|e| format!("Argon2id Fehler: {}", e))?;
let hash_bytes = password_hash.hash.unwrap();
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes.as_bytes()[..32]);
let cipher = Aes256Gcm::new(key);
let nonce_bytes = BASE64.decode(&entry.nonce)
.map_err(|e| format!("Nonce decode error: {}", e))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = BASE64.decode(&entry.ciphertext)
.map_err(|e| format!("Ciphertext decode error: {}", e))?;
let plaintext = cipher.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| format!("Entschlüsselung fehlgeschlagen: {}", e))?;
String::from_utf8(plaintext)
.map_err(|_| "Secret enthält ungültige UTF-8 Zeichen".to_string())
}
pub fn encrypt_secret_value(value: &str, passphrase: &str) -> Result<(String, String, String), String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(passphrase.as_bytes(), &salt)
.map_err(|e| format!("Argon2id Fehler: {}", e))?;
let hash_bytes = password_hash.hash.unwrap();
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes.as_bytes()[..32]);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher.encrypt(&nonce, value.as_bytes())
.map_err(|e| format!("Verschlüsselung fehlgeschlagen: {}", e))?;
Ok((
BASE64.encode(&ciphertext),
BASE64.encode(&nonce),
salt.to_string(),
))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeviceKeyInfo {
pub has_local_key: bool,
pub key_path: String,
pub masked_key: String,
pub is_forge_linked: bool,
}
#[tauri::command]
pub fn get_device_key_info() -> Result<DeviceKeyInfo, String> {
let forge_dir = get_forge_dir();
let key_path = forge_dir.join("master.key");
if key_path.exists() {
if let Ok(key) = std::fs::read_to_string(&key_path) {
let key = key.trim();
if !key.is_empty() {
let masked = if key.len() > 12 {
format!("{}...{}", &key[..6], &key[key.len() - 6..])
} else {
"***".to_string()
};
return Ok(DeviceKeyInfo {
has_local_key: true,
key_path: key_path.to_string_lossy().to_string(),
masked_key: masked,
is_forge_linked: true,
});
}
}
}
Ok(DeviceKeyInfo {
has_local_key: false,
key_path: key_path.to_string_lossy().to_string(),
masked_key: String::new(),
is_forge_linked: false,
})
}
#[tauri::command]
pub fn check_vault_status(env: Option<String>, state: tauri::State<'_, VaultState>) -> Result<VaultStatus, String> {
let active_env = env.unwrap_or_else(|| state.active_env.lock().unwrap().clone());
*state.active_env.lock().unwrap() = active_env.clone();
let path = vault_path(&active_env);
let vault = load_vault_file(&active_env);
let is_unlocked = state.active_session.lock().unwrap().is_some();
let environments = list_available_environments();
Ok(VaultStatus {
exists: path.exists(),
is_unlocked,
secret_count: vault.secrets.len(),
environments,
active_env,
})
}
#[tauri::command]
pub fn unlock_vault(
password: String,
env: Option<String>,
state: tauri::State<'_, VaultState>,
) -> Result<Vec<SecretItem>, String> {
let active_env = env.unwrap_or_else(|| state.active_env.lock().unwrap().clone());
*state.active_env.lock().unwrap() = active_env.clone();
let trimmed = password.trim();
if trimmed.is_empty() {
return Err("Bitte Master-Passwort eingeben!".to_string());
}
let vault = load_vault_file(&active_env);
if let Some((_, entry)) = vault.secrets.iter().next() {
if decrypt_secret_entry(entry, trimmed).is_err() {
return Err("Falsches Master-Passwort!".to_string());
}
}
*state.active_session.lock().unwrap() = Some(UnlockedSession {
passphrase: trimmed.as_bytes().to_vec(),
});
*state.last_active.lock().unwrap() = std::time::Instant::now();
get_unlocked_secrets_internal(&active_env, trimmed)
}
#[tauri::command]
pub async fn lock_vault(
state: tauri::State<'_, VaultState>,
tunnel_state: tauri::State<'_, crate::tunnels::TunnelState>,
log_state: tauri::State<'_, crate::logs::LogStreamState>,
) -> Result<(), String> {
*state.active_session.lock().unwrap() = None;
crate::tunnels::stop_all_active_tunnels(&tunnel_state).await;
crate::logs::stop_all_active_log_streams(&log_state).await;
Ok(())
}
#[tauri::command]
pub fn get_unlocked_secrets(
env: Option<String>,
state: tauri::State<'_, VaultState>,
) -> Result<Vec<SecretItem>, String> {
ensure_unlocked(&state)?;
let guard = state.active_session.lock().unwrap();
let unlocked = guard.as_ref().unwrap();
let pass = unlocked.as_str().to_string();
drop(guard);
let active_env = env.unwrap_or_else(|| state.active_env.lock().unwrap().clone());
*state.last_active.lock().unwrap() = std::time::Instant::now();
get_unlocked_secrets_internal(&active_env, &pass)
}
fn get_unlocked_secrets_internal(env: &str, passphrase: &str) -> Result<Vec<SecretItem>, String> {
let vault = load_vault_file(env);
let mut items = Vec::new();
let now = Utc::now();
for (name, entry) in &vault.secrets {
let value = match decrypt_secret_entry(entry, passphrase) {
Ok(val) => val,
Err(_) => "[Entschlüsselungsfehler]".to_string(),
};
let updated_at_parsed = chrono::DateTime::parse_from_rfc3339(&entry.updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(now);
let age_days = (now - updated_at_parsed).num_days();
items.push(SecretItem {
name: name.clone(),
value,
updated_at: entry.updated_at.clone(),
updated_by: entry.updated_by.clone(),
version: entry.version,
age_days,
project: entry.project.clone(),
});
}
items.sort_by(|a, b| a.name.cmp(&b.name));
Ok(items)
}
#[tauri::command]
pub fn save_secret(
env: String,
name: String,
value: String,
project: Option<String>,
state: tauri::State<'_, VaultState>,
) -> Result<(), String> {
ensure_unlocked(&state)?;
let guard = state.active_session.lock().unwrap();
let unlocked = guard.as_ref().unwrap();
let pass = unlocked.as_str().to_string();
drop(guard);
*state.last_active.lock().unwrap() = std::time::Instant::now();
let mut vault = load_vault_file(&env);
let (ciphertext, nonce, salt) = encrypt_secret_value(&value, &pass)?;
let version = vault.secrets.get(&name).map(|e| e.version + 1).unwrap_or(1);
let proj = project.unwrap_or_else(|| {
vault.secrets.get(&name).map(|e| e.project.clone()).unwrap_or_else(|| "global".to_string())
});
vault.secrets.insert(name, SecretEntry {
ciphertext,
nonce,
salt,
updated_at: Utc::now().to_rfc3339(),
updated_by: "gui-user".to_string(),
version,
project: proj,
});
save_vault_file(&env, &vault)
}
#[tauri::command]
pub fn delete_secret(
env: String,
name: String,
state: tauri::State<'_, VaultState>,
) -> Result<(), String> {
ensure_unlocked(&state)?;
*state.last_active.lock().unwrap() = std::time::Instant::now();
let mut vault = load_vault_file(&env);
vault.secrets.remove(&name);
save_vault_file(&env, &vault)
}
#[tauri::command]
pub fn get_full_master_key(state: tauri::State<'_, VaultState>) -> Result<String, String> {
ensure_unlocked(&state)?;
let guard = state.active_session.lock().unwrap();
if let Some(session) = guard.as_ref() {
Ok(session.as_str().to_string())
} else {
Err("Vault ist gesperrt!".to_string())
}
}
#[tauri::command]
pub fn change_master_password(
new_password: String,
state: tauri::State<'_, VaultState>,
) -> Result<(), String> {
ensure_unlocked(&state)?;
let new_pass = new_password.trim().to_string();
if new_pass.len() < 4 {
return Err("Neues Passwort muss mindestens 4 Zeichen lang sein.".to_string());
}
let current_pass = {
let guard = state.active_session.lock().unwrap();
guard.as_ref().unwrap().as_str().to_string()
};
let environments = list_available_environments();
for env in &environments {
let mut vault = load_vault_file(env);
if vault.secrets.is_empty() {
continue;
}
let mut decrypted_map = Vec::new();
for (name, entry) in &vault.secrets {
let val = decrypt_secret_entry(entry, ¤t_pass)?;
decrypted_map.push((name.clone(), val, entry.project.clone(), entry.version, entry.updated_by.clone()));
}
for (name, val, proj, ver, by) in decrypted_map {
let (ciphertext, nonce, salt) = encrypt_secret_value(&val, &new_pass)?;
vault.secrets.insert(name, SecretEntry {
ciphertext,
nonce,
salt,
updated_at: Utc::now().to_rfc3339(),
updated_by: by,
version: ver,
project: proj,
});
}
save_vault_file(env, &vault)?;
}
let key_file = get_forge_dir().join("master.key");
let _ = std::fs::write(&key_file, &new_pass);
*state.active_session.lock().unwrap() = Some(UnlockedSession {
passphrase: new_pass.into_bytes(),
});
Ok(())
}
#[tauri::command]
pub fn get_all_environments_matrix() -> Result<EnvMatrix, String> {
let environments = list_available_environments();
let mut all_keys_set = std::collections::BTreeSet::new();
let mut env_vaults = HashMap::new();
for env in &environments {
let vault = load_vault_file(env);
for key in vault.secrets.keys() {
all_keys_set.insert(key.clone());
}
env_vaults.insert(env.clone(), vault);
}
let keys: Vec<String> = all_keys_set.into_iter().collect();
let mut presence = HashMap::new();
let mut key_projects = HashMap::new();
for key in &keys {
let mut env_map = HashMap::new();
for env in &environments {
if let Some(vault) = env_vaults.get(env) {
if let Some(entry) = vault.secrets.get(key) {
env_map.insert(env.clone(), true);
key_projects.entry(key.clone()).or_insert_with(|| entry.project.clone());
} else {
env_map.insert(env.clone(), false);
}
} else {
env_map.insert(env.clone(), false);
}
}
presence.insert(key.clone(), env_map);
}
Ok(EnvMatrix {
keys,
environments,
presence,
key_projects,
})
}
#[tauri::command]
pub fn generate_secure_token(token_type: String, length: usize) -> Result<String, String> {
use rand::Rng;
let mut rng = rand::thread_rng();
match token_type.as_str() {
"hex" => {
let bytes_len = if length == 0 { 32 } else { length };
let mut bytes = vec![0u8; bytes_len];
rng.fill(&mut bytes[..]);
Ok(hex::encode(&bytes))
}
"base64" => {
let bytes_len = if length == 0 { 32 } else { length };
let mut bytes = vec![0u8; bytes_len];
rng.fill(&mut bytes[..]);
Ok(BASE64.encode(&bytes))
}
"alphanumeric" | "password" => {
let len = if length == 0 { 24 } else { length };
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
let s: String = (0..len)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();
Ok(s)
}
_ => Err("Unbekannter Tokentyp (erlaubt: hex, base64, password)".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vault_encrypt_decrypt_roundtrip() {
let pass = "test-super-secret-master-pass";
let val = "stripe_sk_live_1234567890abcdef";
let (ct, nonce, salt) = encrypt_secret_value(val, pass).unwrap();
let entry = SecretEntry {
ciphertext: ct,
nonce,
salt,
updated_at: "2026-09-08T00:00:00Z".to_string(),
updated_by: "test".to_string(),
version: 1,
project: "test".to_string(),
};
let decrypted = decrypt_secret_entry(&entry, pass).unwrap();
assert_eq!(decrypted, val);
let wrong = decrypt_secret_entry(&entry, "wrong-pass");
assert!(wrong.is_err());
}
#[test]
fn test_unlock_existing_dev_vault() {
let vault = load_vault_file("dev");
if vault.secrets.is_empty() {
return;
}
let key_file = get_forge_dir().join("master.key");
let master_key = std::fs::read_to_string(&key_file).unwrap_or_default().trim().to_string();
assert!(!master_key.is_empty(), "master.key should exist");
let (_, entry) = vault.secrets.iter().next().unwrap();
let decrypted = decrypt_secret_entry(entry, &master_key);
assert!(decrypted.is_ok(), "Should unlock dev.vault when entering the valid master key!");
let wrong = decrypt_secret_entry(entry, "incorrect-pass");
assert!(wrong.is_err(), "Should reject wrong password!");
}
}