mod api;
mod backup;
mod health;
mod stats;
mod utils;
mod vault;
use crate::api::handle_api_command;
use crate::backup::{BackupCommand, handle_backup_command};
use crate::health::{analyze_password_strength, handle_health_command};
use crate::stats::handle_stats_command;
use crate::utils::{filter_and_sort_items, format_relative_time};
use crate::vault::{VaultCommand, handle_vault_command};
use chamber_import_export::{ExportFormat, detect_format_from_extension, export_items, import_items};
use chamber_password_gen::{
PasswordConfig, generate_complex_password, generate_memorable_password, generate_simple_password,
};
use chamber_vault::{Item, ItemKind, NewItem, Vault, VaultManager};
use clap::{Parser, Subcommand};
use color_eyre::Result;
use color_eyre::eyre::eyre;
use std::path::PathBuf;
use std::str::FromStr;
use time::OffsetDateTime;
#[derive(Parser, Debug)]
#[command(
name = "chamber",
about = "A secure, local-first secrets manager with encrypted storage and multiple vault support",
long_about = "Chamber is a secure secrets manager that stores your passwords, API keys, and other \
sensitive information using strong encryption. All data is stored locally with \
zero-knowledge architecture.\n\n\
Features:\n\
• ChaCha20-Poly1305 authenticated encryption\n\
• Argon2 key derivation for master passwords\n\
• Multiple vault support for organization\n\
• Automatic backup system with retention policies\n\
• Import/export in JSON, CSV, and Chamber formats\n\
• Terminal UI and command-line interface\n\
• Secure password generation\n\n\
Quick start:\n\
1. chamber init # Initialize your first vault\n\
2. chamber add -n \"github-token\" -k apikey -v \"your-token\"\n\
3. chamber list # View your secrets\n\
4. chamber # Launch terminal interface"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Api {
#[arg(
short,
long,
default_value = "127.0.0.1:3000",
help = "Address to bind the API server to"
)]
bind: String,
#[arg(short, long, help = "Port to bind the API server to")]
port: Option<u16>,
},
Init,
Add {
#[arg(short, long)]
name: String,
#[arg(short, long, default_value = "note")]
kind: String,
#[arg(short, long)]
value: Option<String>,
#[arg(long, help = "Generate a secure password automatically")]
generate: bool,
#[arg(long, default_value = "16", help = "Length of generated password")]
length: Option<usize>,
#[arg(long, help = "Generate simple password (alphanumeric only)")]
simple: bool,
#[arg(long, help = "Generate complex password (all character types)")]
complex: bool,
#[arg(long, help = "Generate memorable password")]
memorable: bool,
},
Health {
#[arg(long, help = "Show detailed health analysis")]
detailed: bool,
},
List {
#[arg(long, help = "Filter by item type (password, apikey, envvar, etc.)")]
r#type: Option<String>,
#[arg(long, help = "Show items created since date (e.g., '1 week ago', '3 days ago')")]
since: Option<String>,
#[arg(long, help = "Show N most recent items")]
recent: Option<usize>,
#[arg(long, help = "Filter by name pattern (supports wildcards like GitHub*)")]
name: Option<String>,
},
Get {
#[arg(short, long)]
name: String,
#[arg(long, help = "Copy value to clipboard instead of displaying it")]
copy_value: bool,
},
Generate {
#[arg(short, long, default_value = "16")]
length: usize,
#[arg(long)]
simple: bool,
#[arg(long)]
complex: bool,
#[arg(long)]
memorable: bool,
#[arg(long)]
no_uppercase: bool,
#[arg(long)]
no_lowercase: bool,
#[arg(long)]
no_digits: bool,
#[arg(long)]
no_symbols: bool,
#[arg(long)]
include_ambiguous: bool,
#[arg(short, long)]
count: Option<usize>,
},
Export {
#[arg(short, long)]
output: PathBuf,
#[arg(short, long)]
format: Option<String>,
#[arg(long)]
include_metadata: bool,
},
Import {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
format: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
skip_duplicates: bool,
},
Stats,
Update {
#[arg(short, long, help = "Name of the item to update")]
name: String,
#[arg(short, long, help = "New value for the item")]
value: Option<String>,
#[arg(long, help = "Generate a new secure password automatically")]
generate: bool,
#[arg(long, default_value = "16", help = "Length of generated password")]
length: Option<usize>,
#[arg(long, help = "Generate simple password (alphanumeric only)")]
simple: bool,
#[arg(long, help = "Generate complex password (all character types)")]
complex: bool,
#[arg(long, help = "Generate memorable password")]
memorable: bool,
#[arg(long, help = "Copy updated value to clipboard")]
copy: bool,
#[arg(long, help = "Specify which vault to search in (otherwise searches all)")]
vault: Option<String>,
},
#[command(subcommand)]
Backup(BackupCommand),
#[command(subcommand)]
Registry(VaultCommand),
}
#[allow(clippy::cognitive_complexity)]
#[allow(clippy::too_many_lines)]
pub async fn handle_command(cmd: Commands) -> Result<()> {
match cmd {
Commands::Api { bind, port } => handle_api_command(bind, port).await?,
Commands::Init => {
let mut vault = Vault::open_or_create(None)?;
if vault.is_initialized() {
println!("Vault already initialized at {}", vault.db_path().display());
} else {
let master = prompt_secret("Create master key: ")?;
let confirm = prompt_secret("Confirm master key: ")?;
if master != confirm {
return Err(eyre!("Master keys do not match"));
}
vault.initialize(&master)?;
println!("Initialized vault at {}", vault.db_path().display());
}
}
Commands::Add {
name,
kind,
value,
generate,
length,
simple,
complex,
memorable,
} => {
let mut vault = Vault::open_or_create(None)?;
let master = prompt_secret("Enter master key: ")?;
vault.unlock(&master)?;
let item_value = if generate {
if value.is_some() {
return Err(eyre!("Cannot use both --value and --generate options together"));
}
let password_length = length.unwrap_or(16);
let generated_password = if memorable {
generate_memorable_password()
} else if simple {
generate_simple_password(password_length)?
} else if complex {
generate_complex_password(password_length)?
} else {
PasswordConfig::new()
.with_length(password_length)
.with_uppercase(true)
.with_lowercase(true)
.with_digits(true)
.with_symbols(true)
.with_exclude_ambiguous(true)
.generate()?
};
println!("Generated password: {generated_password}");
generated_password
} else {
match value {
Some(v) => v,
None => prompt_secret("Enter value: ")?,
}
};
let kind = match kind.as_str() {
"pass" | "password" => ItemKind::Password,
"env" | "envvar" => ItemKind::EnvVar,
"ssh" | "sshkey" => ItemKind::SshKey,
"apikey" | "api_key" | "api-key" | "token" => ItemKind::ApiKey,
"certificate" | "cert" | "ssl" | "tls" => ItemKind::Certificate,
"database" | "db" | "connection" => ItemKind::Database,
_ => ItemKind::Note,
};
let item = NewItem {
name: name.clone(),
kind,
value: item_value,
};
match vault.create_item(&item) {
Ok(()) => {
println!("✅ Item '{name}' added successfully.");
}
Err(e) => {
let msg = e.to_string();
if msg.contains("already exists") {
println!("❌ Item '{name}' already exists. Use a different name or update it.");
return Ok(());
}
return Err(e);
}
}
}
Commands::Health { detailed } => {
let mut vault = match Vault::open_active() {
Ok(vault) => vault,
Err(_) => {
Vault::open_default()?
}
};
let master = prompt_secret("Enter master key: ")?;
vault.unlock(&master)?;
if !vault.is_unlocked() {
eprintln!("❌ Vault is locked. Please unlock it first.");
std::process::exit(1);
}
handle_health_command(&vault, detailed)?;
}
Commands::List {
r#type,
since,
recent,
name,
} => {
let mut vault = Vault::open_or_create(None)?;
let master = prompt_secret("Enter master key: ")?;
vault.unlock(&master)?;
let all_items = vault.list_items()?;
let filtered_items =
match filter_and_sort_items(all_items, r#type.as_deref(), since.as_deref(), recent, name.as_deref()) {
Ok(items) => items,
Err(e) => {
return Err(eyre!("Error while filtering items: {e}"));
}
};
if filtered_items.is_empty() {
println!("No items found matching the criteria.");
return Ok(());
}
println!("Found {} item(s):", filtered_items.len());
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
for item in filtered_items {
let age = format_relative_time(item.created_at);
println!(
"• {} [{}] - created {} ({})",
item.name,
item.kind.display_name(),
age,
item.created_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "unknown".to_string())
);
}
}
Commands::Get { name, copy_value } => {
let mut vault = Vault::open_or_create(None)?;
let master = prompt_secret("Enter master key: ")?;
vault.unlock(&master)?;
if let Some(item) = vault.get_item_by_name(&name)? {
if copy_value {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| eyre!("Failed to access clipboard: {}", e))?;
clipboard
.set_text(&item.value)
.map_err(|e| eyre!("Failed to copy to clipboard: {}", e))?;
println!("✅ Value for '{name}' copied to clipboard");
} else {
println!("{}", item.value);
}
} else {
return Err(eyre!("Item not found"));
}
}
Commands::Generate {
length,
simple,
complex,
memorable,
no_uppercase,
no_lowercase,
no_digits,
no_symbols,
include_ambiguous,
count,
} => {
let count = count.unwrap_or(1);
for i in 0..count {
let password = if memorable {
generate_memorable_password()
} else if simple {
generate_simple_password(length)?
} else if complex {
generate_complex_password(length)?
} else {
PasswordConfig::new()
.with_length(length)
.with_uppercase(!no_uppercase)
.with_lowercase(!no_lowercase)
.with_digits(!no_digits)
.with_symbols(!no_symbols)
.with_exclude_ambiguous(!include_ambiguous)
.generate()?
};
if count > 1 {
println!("{}: {password}", i + 1);
} else {
println!("{password}");
}
}
}
Commands::Export {
output,
format,
include_metadata: _,
} => {
let mut vault = Vault::open_or_create(None)?;
let master = prompt_secret("Enter master key: ")?;
vault.unlock(&master)?;
let items = vault.list_items()?;
if items.is_empty() {
println!("No items to export.");
return Ok(());
}
let export_format = if let Some(format_str) = format {
ExportFormat::from_str(&format_str)?
} else {
detect_format_from_extension(&output).unwrap_or(ExportFormat::Json)
};
export_items(&items, &export_format, &output)?;
println!(
"Exported {} items to {} (format: {:?})",
items.len(),
output.display(),
export_format
);
}
Commands::Import {
input,
format,
dry_run,
skip_duplicates,
} => {
if !input.exists() {
return Err(eyre!("Input file does not exist: {}", input.display()));
}
let import_format = if let Some(format_str) = format {
ExportFormat::from_str(&format_str)?
} else {
detect_format_from_extension(&input)
.ok_or_else(|| eyre!("Could not detect file format. Please specify with --format"))?
};
let new_items = import_items(&input, &import_format)?;
if new_items.is_empty() {
println!("No items found in import file.");
return Ok(());
}
println!("Found {} items to import", new_items.len());
if dry_run {
println!("DRY RUN - Items that would be imported:");
for item in &new_items {
println!(" - {} [{}]", item.name, item.kind.as_str());
}
return Ok(());
}
let master = prompt_secret("Enter master key: ")?;
let mut vault = Vault::open_or_create(None)?;
vault.unlock(&master)?;
let existing_items = vault.list_items()?;
let existing_names: std::collections::HashSet<String> =
existing_items.iter().map(|item| item.name.clone()).collect();
let mut imported_count = 0;
let mut skipped_count = 0;
for item in new_items {
if existing_names.contains(&item.name) {
if skip_duplicates {
println!("Skipping duplicate: {}", item.name);
skipped_count += 1;
continue;
}
println!(
"Warning: Item '{}' already exists and will be skipped. Use --skip-duplicates to suppress this warning.",
item.name
);
skipped_count += 1;
continue;
}
match vault.create_item(&item) {
Ok(()) => {
imported_count += 1;
}
Err(e) => {
println!("Failed to import item: {e}");
}
}
}
println!("Import complete: {imported_count} imported, {skipped_count} skipped");
}
Commands::Stats => {
let mut vault = match Vault::open_active() {
Ok(vault) => vault,
Err(_) => {
Vault::open_default()?
}
};
let master = prompt_secret("Enter master key: ")?;
vault.unlock(&master)?;
if !vault.is_unlocked() {
eprintln!("❌ Vault is locked. Please unlock it first.");
std::process::exit(1);
}
handle_stats_command(&vault)?;
}
Commands::Update {
name,
value,
generate,
length,
simple,
complex,
memorable,
copy,
vault: vault_name,
} => {
let (vault, existing_item, found_vault_name) = if let Some(specific_vault) = vault_name.clone() {
find_item_in_specific_vault(&name, &specific_vault)?
} else {
find_item_across_vaults(&name)?
};
if let (mut vault, Some(item), vault_name) = (vault, existing_item, found_vault_name) {
println!("🔍 Found '{name}' in vault: {vault_name}");
println!("📝 Updating '{}' [{}]", item.name, item.kind.display_name());
let new_value = if generate {
if value.is_some() {
return Err(eyre!("Cannot use both --value and --generate options together"));
}
let password_length = length.unwrap_or(16);
let generated_password = if memorable {
generate_memorable_password()
} else if simple {
generate_simple_password(password_length)?
} else if complex {
generate_complex_password(password_length)?
} else {
PasswordConfig::new()
.with_length(password_length)
.with_uppercase(true)
.with_lowercase(true)
.with_digits(true)
.with_symbols(true)
.with_exclude_ambiguous(true)
.generate()?
};
println!("🔐 Generated new password: {generated_password}");
generated_password
} else if let Some(v) = value {
println!("📝 Using provided value");
v
} else {
let display_current = if matches!(item.kind, ItemKind::Password | ItemKind::ApiKey) {
format!("{}***", &item.value.chars().take(3).collect::<String>())
} else {
#[allow(clippy::redundant_clone)]
item.value.clone()
};
println!("Current value: {display_current}");
prompt_secret("Enter new value (or press Ctrl+C to cancel): ")?
};
if new_value == item.value {
println!("⚠️ New value is the same as the current value. No changes made.");
return Ok(());
}
match vault.update_item(item.id, &new_value) {
Ok(()) => {
println!("✅ Item '{name}' updated successfully in vault '{vault_name}'.");
if matches!(item.kind, ItemKind::Password | ItemKind::ApiKey) {
let strength = analyze_password_strength(&new_value);
println!("🔒 Password strength: {strength}");
}
if copy {
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&new_value) {
println!("⚠️ Warning: Failed to copy to clipboard: {e}");
} else {
println!("📋 New value copied to clipboard.");
}
}
Err(e) => {
println!("⚠️ Warning: Failed to access clipboard: {e}");
}
}
}
println!(
"🕐 Updated at: {}",
OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "unknown".to_string())
);
}
Err(e) => {
return Err(eyre!("Failed to update item: {}", e));
}
}
} else {
println!(
"❌ Item '{}' not found{}.",
name,
if vault_name.is_some() {
format!(" in vault '{}'", vault_name.unwrap())
} else {
" in any vault".to_string()
}
);
suggest_similar_items_across_vaults(&name)?;
println!();
println!("💡 Use 'chamber list' to see items in current vault");
println!("💡 Use 'chamber registry list' to see all vaults");
println!("💡 Use 'chamber add' to create a new item");
}
}
Commands::Backup(backup_cmd) => {
let vault = Vault::open_or_create(None)?;
let _ = handle_backup_command(vault, backup_cmd);
}
Commands::Registry(registry_cmd) => {
let mut manager = VaultManager::new()?;
handle_vault_command(&mut manager, registry_cmd)?;
}
}
Ok(())
}
fn is_weak_password(password: &str) -> bool {
if password.len() < 10 {
return true;
}
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_special = password.chars().any(|c| !c.is_ascii_alphanumeric());
!(has_lower && has_upper && has_digit && has_special)
}
const fn get_type_emoji(item_type: ItemKind) -> &'static str {
match item_type {
ItemKind::Password => "🔑",
ItemKind::EnvVar => "🌐",
ItemKind::Note => "📝",
ItemKind::ApiKey | ItemKind::SshKey => "🔐",
ItemKind::Certificate => "📜",
ItemKind::Database => "🗄️",
ItemKind::CreditCard => "💳",
ItemKind::SecureNote => "🔒",
ItemKind::Identity => "🆔",
ItemKind::Server => "🖥️",
ItemKind::WifiPassword => "📶",
ItemKind::License => "📄",
ItemKind::BankAccount => "🏦",
ItemKind::Document => "📋",
ItemKind::Recovery => "🔄",
ItemKind::OAuth => "🎫",
}
}
fn prompt_secret(prompt: &str) -> Result<String> {
use std::io::{Write, stdout};
print!("{prompt}");
stdout().flush()?;
let pass = rpassword::prompt_password("")?;
Ok(pass)
}
fn find_item_across_vaults(item_name: &str) -> Result<(Vault, Option<Item>, String)> {
let manager = VaultManager::new()?;
let vaults = manager.list_vaults();
if let Ok(mut vault) = Vault::open_active() {
if let Ok(Some(vault_id)) = vault.get_vault_id() {
let master = prompt_secret(&format!(
"Enter master key for active vault ({}): ",
get_vault_display_name(&manager, &vault_id)
))?;
if vault.unlock(&master).is_ok() {
if let Ok(Some(item)) = vault.get_item_by_name(item_name) {
let vault_name = get_vault_display_name(&manager, &vault_id);
return Ok((vault, Some(item), vault_name));
}
}
}
}
let mut tried_vaults = Vec::new();
for vault_info in vaults {
if let Ok(active_vault) = Vault::open_active() {
if let Ok(Some(active_id)) = active_vault.get_vault_id() {
if vault_info.id == active_id {
continue;
}
}
}
println!("🔍 Searching in vault: {} ({})", vault_info.name, vault_info.category);
let master = prompt_secret(&format!("Enter master key for '{}': ", vault_info.name))?;
match Vault::open_by_id(&vault_info.id) {
Ok(mut vault) => match vault.unlock(&master) {
Ok(()) => {
tried_vaults.push(vault_info.name.clone());
if let Ok(Some(item)) = vault.get_item_by_name(item_name) {
return Ok((vault, Some(item), vault_info.name.clone()));
}
}
Err(_) => {
println!("❌ Failed to unlock vault '{}' (incorrect password?)", vault_info.name);
}
},
Err(e) => {
println!("❌ Failed to open vault '{}': {}", vault_info.name, e);
}
}
}
if !tried_vaults.is_empty() {
println!(
"🔍 Searched in {} vault(s): {}",
tried_vaults.len(),
tried_vaults.join(", ")
);
}
Ok((Vault::open_default()?, None, String::new()))
}
fn get_vault_display_name(manager: &VaultManager, vault_id: &str) -> String {
if let Some(vault_info) = manager.list_vaults().iter().find(|v| v.id == vault_id) {
format!("{} ({})", vault_info.name, vault_info.category)
} else {
vault_id.to_string()
}
}
fn suggest_similar_items_across_vaults(item_name: &str) -> Result<()> {
let manager = VaultManager::new()?;
let vaults = manager.list_vaults();
let mut suggestions = Vec::new();
for vault_info in vaults {
if let Ok(vault) = Vault::open_by_id(&vault_info.id) {
if vault.is_unlocked() {
if let Ok(items) = vault.list_items() {
for item in items {
if is_similar_name(&item.name, item_name) {
suggestions.push((item.name, item.kind, vault_info.name.clone()));
}
}
}
}
}
}
if !suggestions.is_empty() {
println!("💡 Did you mean one of these?");
for (name, kind, vault_name) in suggestions.into_iter().take(5) {
println!(" • {} [{}] in vault '{}'", name, kind.display_name(), vault_name);
}
}
Ok(())
}
fn is_similar_name(item_name: &str, search_name: &str) -> bool {
let item_lower = item_name.to_lowercase();
let search_lower = search_name.to_lowercase();
if item_lower.contains(&search_lower) || search_lower.contains(&item_lower) {
return true;
}
if search_lower.len() >= 3 {
search_lower.chars().collect::<Vec<_>>().windows(3).any(|window| {
let substr: String = window.iter().collect();
item_lower.contains(&substr)
})
} else {
false
}
}
fn find_item_in_specific_vault(item_name: &str, vault_identifier: &str) -> Result<(Vault, Option<Item>, String)> {
let manager = VaultManager::new()?;
let vaults = manager.list_vaults();
let vault_info = vaults
.iter()
.find(|v| v.name.eq_ignore_ascii_case(vault_identifier) || v.id == vault_identifier)
.ok_or_else(|| eyre!("Vault '{}' not found", vault_identifier))?;
let mut vault = Vault::open_by_id(&vault_info.id)?;
let master = prompt_secret(&format!("Enter master key for '{}': ", vault_info.name))?;
vault.unlock(&master)?;
let item = vault.get_item_by_name(item_name)?;
Ok((vault, item, vault_info.name.clone()))
}
#[cfg(test)]
mod handle_command_tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn tmp_path(name: &str) -> PathBuf {
let now = time::OffsetDateTime::now_utc().unix_timestamp_nanos();
let pid = std::process::id();
std::env::temp_dir().join(format!("chamber_cli_{name}_{pid}_{now}"))
}
#[tokio::test]
async fn test_generate_simple_ok() {
let cmd = Commands::Generate {
length: 12,
simple: true,
complex: false,
memorable: false,
no_uppercase: false,
no_lowercase: false,
no_digits: false,
no_symbols: false,
include_ambiguous: false,
count: None,
};
let res = handle_command(cmd);
assert!(res.await.is_ok());
}
#[tokio::test]
async fn test_generate_complex_ok() {
let cmd = Commands::Generate {
length: 24,
simple: false,
complex: true,
memorable: false,
no_uppercase: false,
no_lowercase: false,
no_digits: false,
no_symbols: false,
include_ambiguous: false,
count: Some(1),
};
let res = handle_command(cmd);
assert!(res.await.is_ok());
}
#[tokio::test]
async fn test_generate_memorable_ok() {
let cmd = Commands::Generate {
length: 16, simple: false,
complex: false,
memorable: true,
no_uppercase: false,
no_lowercase: false,
no_digits: false,
no_symbols: false,
include_ambiguous: false,
count: None,
};
let res = handle_command(cmd);
assert!(res.await.is_ok());
}
#[tokio::test]
async fn test_generate_count_multiple_ok() {
let cmd = Commands::Generate {
length: 10,
simple: true,
complex: false,
memorable: false,
no_uppercase: false,
no_lowercase: false,
no_digits: false,
no_symbols: false,
include_ambiguous: false,
count: Some(3),
};
let res = handle_command(cmd);
assert!(res.await.is_ok());
}
#[tokio::test]
async fn test_generate_custom_no_character_sets_err() {
let cmd = Commands::Generate {
length: 10,
simple: false,
complex: false,
memorable: false,
no_uppercase: true,
no_lowercase: true,
no_digits: true,
no_symbols: true,
include_ambiguous: false,
count: None,
};
let err = handle_command(cmd).await.unwrap_err().to_string();
println!("{err}");
assert!(err.contains("At least one character set must be enabled"));
}
#[tokio::test]
async fn test_import_missing_file_err() {
let missing = tmp_path("missing").with_extension("json");
let cmd = Commands::Import {
input: missing,
format: Some("json".to_string()),
dry_run: false,
skip_duplicates: false,
};
let err = handle_command(cmd).await.unwrap_err().to_string();
assert!(err.contains("Input file does not exist"));
}
}