diff --git a/Cargo.lock b/Cargo.lock index 1defce1..09acb76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", "tempfile", "thiserror", "validator", diff --git a/Cargo.toml b/Cargo.toml index 7539245..399ac2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ dialoguer = "0.12.0" chrono = "0.4.42" indoc = "2.0.6" regex = "1.11.2" +serde_yaml = "0.9.34" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/README.md b/README.md index 4c6a51e..f4f9c60 100644 --- a/README.md +++ b/README.md @@ -10,49 +10,78 @@ files or sprinkling environment variables everywhere. certs—with a provider, then either fetch them directly or run your command through `gman` to inject what it needs as environment variables, flags, or file content. +## Quick Examples: Before vs After + +These examples show how `gman` reduces friction when running tools that need secrets. The run profile snippets referenced +here are shown later in this README under [Run Configurations](#run-configurations). + +### AWS CLI (env vars) +**Before:** +```shell +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +aws sts get-caller-identity +``` + +**After (with a run profile named `aws`):** +```shell +gman aws sts get-caller-identity +```` + +### Docker (flags) +**Before:** +```shell +docker run -e API_KEY=... -e DB_PASSWORD=... my/image +``` + +**After (with a run profile named `docker` that uses `-e` flags):** +```shell +gman docker run my/image +``` + - Pro Tip: Run `gman --dry-run docker run my/image` to preview the full command with masked values + +### Config file injection +**Before:** +```shell +# Place plaintext secrets directly in configuration files (not recommended) +# Or use a tool like `envsubst` to replace placeholders; e.g. +export RADARR_API_KEY=... +export SONARR_API_KEY=... +envsubst < ~/.config/managarr/config.yml.template > ~/.config/managarr/config.yml +managarr radarr list movies +``` + +**After (with a run profile named `managarr` that injects files):** +```shell +# `gman` injects secret values into the file(s), runs the command, then restores the original content +gman managarr radarr list movies +``` + +### Example roundtrip of adding, retrieving, and using a secret +```shell +# Add a secret (value read from stdin) +echo "mySuperSecretValue" | gman add my_api_key +# Retrieve a secret +gman get my_api_key +# Use a secret in a wrapped command (with an 'aws' run profile defined) +gman aws sts get-caller-identity +``` + ## Features -- Secure encryption for stored secrets -- Pluggable providers (local by default; more can be added) -- Git sync for local vaults to move secrets across machines -- Command wrapping to inject secrets for any program -- Customizable run profiles (env, flags, or files) -- Consistent secret naming: input is snake_case; injected as UPPER_SNAKE_CASE -- Direct retrieval via `gman get ...` -- Dry-run to preview wrapped commands and secret injection - -## Example Use Cases - -### Create/Get/Delete Secrets Securely As You Need From Any Configured Provider - -```shell -# Add a secret to the 'local' provider -echo "someApiKey" | gman add my_api_key - -# Retrieve a secret from the 'aws_secrets_manager' provider -gman get -p aws_secrets_manager db_password - -# Delete a secret from the 'local' provider -gman delete my_api_key -``` - -### Automatically Inject Secrets Into Any Command - -```shell -# Can inject secrets as environment variables into the 'aws' CLI command -gman aws sts get-caller-identity - -# Inject secrets into 'docker run' command via '-e' flags -gman docker run --rm --entrypoint env busybox | grep -i 'token' - -# Inject secrets into configuration files automatically for the 'managarr' application -gman managarr -``` +- **Secure encryption** for stored secrets +- **Pluggable providers** (local by default; more planned) +- **Git sync for local vaults** to move secrets across machines +- **Command wrapping** to inject secrets for any program +- **Customizable run profiles** (env, flags, or files) +- **Consistent secret naming**: input is snake_case; injected as UPPER_SNAKE_CASE +- **Direct secret retrieval** via `gman get ...` +- **Dry-run** to preview wrapped commands and secret injection ## Installation ### Cargo -If you have Cargo installed, then you can install gman from Crates.io: +If you have Cargo installed, then you can install `gman` from Crates.io: ```shell cargo install gman @@ -139,7 +168,7 @@ Similar to [Ansible Vault](https://docs.ansible.com/ansible/latest/vault_guide/v `password_file` configuration option. If you choose to use a password file, ensure that it is secured with appropriate file permissions (e.g., `chmod 600 ~/.gman_password`). The default file for the password file is `~/.gman_password`. -For use across multiple systems, `gman` can sync with a remote Git repository. +For use across multiple systems, `gman` can sync with a remote Git repository (requires `git` to be installed). **Important Notes for Git Sync:** - You **must** create the remote repository on your Git provider (e.g., GitHub) *before* attempting to sync. @@ -161,6 +190,25 @@ providers: git_user_email: "your.email@example.com" ``` +Repository layout and file tracking +- By default (no sync), secrets are stored in a single file: `~/.config/gman/vault.yml`. +- After configuring a remote and running `gman sync` for the first time: + - A dedicated repository directory is created under the config dir, derived from the remote name, e.g. `~/.config/gman/.vault` or `~/.config/gman/.test-vault`. + - The existing `vault.yml` is moved into that directory as `~/.config/gman/./vault.yml`. + - Only `vault.yml` is tracked and committed in that repository; other files in the config directory are ignored. +- With multiple `local` providers each pointing at different remotes, each gets its own `.repo-name` directory, so you can switch between isolated sets of secrets. + +Security and encryption basics +- Client-side encryption: Secrets are encrypted before being written to disk. The local provider uses Argon2id for key + derivation and XChaCha20-Poly1305 (AEAD) for encryption/authentication. +- Strong defaults: A unique random salt and nonce are generated with the OS RNG for every encryption; Argon2id parameters + are tuned for interactive usage and can evolve in future versions. +- Tamper detection: The AEAD ensures decryption fails if the password is wrong or the ciphertext is modified. +- Envelope format: The stored value encodes header, version, KDF params, and base64-encoded salt, nonce, and ciphertext + to enable robust, portable decryption. +- Memory hygiene: Sensitive buffers are wiped after use (zeroized), and secrets are handled with types (like SecretString) + that reduce accidental exposure through logs and debug prints. No plaintext secrets are logged. + ## Run Configurations Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are @@ -341,15 +389,16 @@ Example: two AWS Secrets Manager providers named `lab` and `prod`. default_provider: prod providers: - name: lab - provider: aws_secrets_manager - # Additional provider-specific settings (e.g., region, role_arn, profile) - # region: us-east-1 - # role_arn: arn:aws:iam::111111111111:role/lab-access + provider: local + password_file: /home/user/.lab_gman_password + git_branch: main + git_remote_url: git@github.com:username/lab-vault.git - name: prod - provider: aws_secrets_manager - # region: us-east-1 - # role_arn: arn:aws:iam::222222222222:role/prod-access + provider: local + password_file: /home/user/.prod_gman_password + git_branch: main + git_remote_url: git@github.com:username/prod-vault.git run_configs: - name: aws diff --git a/src/bin/gman/cli.rs b/src/bin/gman/cli.rs index 34c55cc..0481424 100644 --- a/src/bin/gman/cli.rs +++ b/src/bin/gman/cli.rs @@ -17,7 +17,7 @@ const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; pub fn wrap_and_run_command( secrets_provider: Box, config: &Config, - provider_config: &ProviderConfig, + provider_config: &ProviderConfig, tokens: Vec, profile_name: Option, dry_run: bool, @@ -270,7 +270,7 @@ mod tests { fn set_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> { Ok(()) } - fn delete_secret(&self, _key: &str) -> Result<()> { + fn delete_secret(&self, _config: &ProviderConfig, _key: &str) -> Result<()> { Ok(()) } fn sync(&self, _config: &mut ProviderConfig) -> Result<()> { @@ -345,7 +345,7 @@ mod tests { #[test] fn test_wrap_and_run_command_no_profile() { let cfg = Config::default(); - let provider_cfg = ProviderConfig::default(); + let provider_cfg = ProviderConfig::default(); let prov: Box = Box::new(DummyProvider); let tokens = vec![OsString::from("echo"), OsString::from("hi")]; let err = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true).unwrap_err(); @@ -367,7 +367,7 @@ mod tests { run_configs: Some(vec![run_cfg]), ..Config::default() }; - let provider_cfg = ProviderConfig::default(); + let provider_cfg = ProviderConfig::default(); let prov: Box = Box::new(DummyProvider); // Capture stderr for dry_run preview diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index 3f4aec3..1f02682 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -41,11 +41,11 @@ enum OutputFormat { )] struct Cli { /// Specify the output format - #[arg(short, long, value_enum)] + #[arg(short, long, global = true, value_enum, env = "GMAN_OUTPUT")] output: Option, /// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local')) - #[arg(long, value_enum)] + #[arg(long, value_enum, global = true, env = "GMAN_PROVIDER")] provider: Option, /// Specify a run profile to use when wrapping a command @@ -53,7 +53,7 @@ struct Cli { profile: Option, /// Output the command that will be run instead of executing it - #[arg(long)] + #[arg(long, global = true)] dry_run: bool, #[command(subcommand)] @@ -162,14 +162,16 @@ fn main() -> Result<()> { } Commands::Delete { name } => { let snake_case_name = name.to_snake_case().to_uppercase(); - secrets_provider.delete_secret(&snake_case_name).map(|_| { - if cli.output.is_none() { - println!("✓ Secret '{snake_case_name}' deleted from the vault.") - } - })?; + secrets_provider + .delete_secret(&provider_config, &snake_case_name) + .map(|_| { + if cli.output.is_none() { + println!("✓ Secret '{snake_case_name}' deleted from the vault.") + } + })?; } Commands::List {} => { - let secrets = secrets_provider.list_secrets()?; + let secrets = secrets_provider.list_secrets(&provider_config)?; if secrets.is_empty() { match cli.output { Some(OutputFormat::Json) => { diff --git a/src/config.rs b/src/config.rs index 7165ea3..8edf3d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -218,8 +218,8 @@ impl Config { /// /// ```no_run /// # use gman::config::Config; - /// let provider_config = Config::default().extract_provider_config(None); - /// println!("using provider config: {}", provider_config.unwrap().name); + /// let provider_config = Config::default().extract_provider_config(None).unwrap(); + /// println!("using provider config: {:?}", provider_config.name); /// ``` pub fn extract_provider_config(&self, provider_name: Option) -> Result { let name = provider_name diff --git a/src/providers/git_sync.rs b/src/providers/git_sync.rs index bb46803..6a6f697 100644 --- a/src/providers/git_sync.rs +++ b/src/providers/git_sync.rs @@ -4,7 +4,7 @@ use dialoguer::Confirm; use dialoguer::theme::ColorfulTheme; use indoc::formatdoc; use log::debug; -use std::env; +use std::{env, fs}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use validator::Validate; @@ -25,12 +25,28 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> { opts.validate() .with_context(|| "invalid git sync options")?; let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339()); - let repo_dir = confy::get_configuration_file_path("gman", "vault") + let config_dir = confy::get_configuration_file_path("gman", "vault") .with_context(|| "get config dir")? .parent() .map(Path::to_path_buf) - .ok_or_else(|| anyhow!("Failed to determine repo dir"))?; - std::fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?; + .ok_or_else(|| anyhow!("Failed to determine config dir"))?; + + let remote_url = opts.remote_url.as_ref().expect("no remote url defined"); + let repo_name = repo_name_from_url(remote_url); + let repo_dir = config_dir.join(format!(".{}", repo_name)); + fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?; + + // Move the default vault into the repo dir on first sync so only vault.yml is tracked. + let default_vault = confy::get_configuration_file_path("gman", "vault") + .with_context(|| "get default vault path")?; + let repo_vault = repo_dir.join("vault.yml"); + if default_vault.exists() && !repo_vault.exists() { + fs::rename(&default_vault, &repo_vault) + .with_context(|| format!("move {} -> {}", default_vault.display(), repo_vault.display()))?; + } else if !repo_vault.exists() { + // Ensure an empty vault exists to allow initial commits + fs::write(&repo_vault, "{}\n").with_context(|| format!("create {}", repo_vault.display()))?; + } let git = resolve_git(opts.git_executable.as_ref())?; ensure_git_available(&git)?; @@ -42,7 +58,6 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> { .trim() .to_string(); let branch = opts.branch.as_ref().expect("no target branch defined"); - let remote_url = opts.remote_url.as_ref().expect("no remote url defined"); debug!( "{}", @@ -74,7 +89,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> { // Stage and commit any subsequent local changes after aligning with remote // so we don't merge uncommitted local state. - stage_all(&git, &repo_dir)?; + stage_vault_only(&git, &repo_dir)?; commit_now(&git, &repo_dir, &commit_message)?; @@ -228,8 +243,8 @@ fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> { Ok(()) } -fn stage_all(git: &Path, repo: &Path) -> Result<()> { - run_git(git, repo, &["add", "-A"])?; +fn stage_vault_only(git: &Path, repo: &Path) -> Result<()> { + run_git(git, repo, &["add", "vault.yml"])?; Ok(()) } @@ -321,6 +336,16 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> { Ok(()) } +pub fn repo_name_from_url(url: &str) -> String { + let mut s = url; + if let Some(idx) = s.rfind('/') { + s = &s[idx + 1..]; + } else if let Some(idx) = s.rfind(':') { + s = &s[idx + 1..]; + } + s.trim_end_matches(".git").to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -370,4 +395,12 @@ mod tests { env::remove_var("GIT_EXECUTABLE"); } } + + #[test] + fn test_repo_name_from_url() { + assert_eq!(repo_name_from_url("git@github.com:user/vault.git"), "vault"); + assert_eq!(repo_name_from_url("https://github.com/user/test-vault.git"), "test-vault"); + assert_eq!(repo_name_from_url("ssh://git@example.com/x/y/z.git"), "z"); + assert_eq!(repo_name_from_url("git@example.com:ns/repo"), "repo"); + } } diff --git a/src/providers/local.rs b/src/providers/local.rs index cd34ad0..762e774 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -2,10 +2,11 @@ use anyhow::{anyhow, bail, Context}; use secrecy::{ExposeSecret, SecretString}; use std::collections::HashMap; use std::fs; +use std::path::{Path, PathBuf}; use zeroize::Zeroize; use crate::config::ProviderConfig; -use crate::providers::git_sync::{sync_and_push, SyncOpts}; +use crate::providers::git_sync::{repo_name_from_url, sync_and_push, SyncOpts}; use crate::providers::SecretProvider; use crate::{ ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION, @@ -69,7 +70,8 @@ impl SecretProvider for LocalProvider { } fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result { - let vault: HashMap = confy::load("gman", "vault").unwrap_or_default(); + let vault_path = active_vault_path(config)?; + let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let envelope = vault .get(key) .with_context(|| format!("key '{key}' not found in the vault"))?; @@ -82,7 +84,8 @@ impl SecretProvider for LocalProvider { } fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> { - let mut vault: HashMap = confy::load("gman", "vault").unwrap_or_default(); + let vault_path = active_vault_path(config)?; + let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); if vault.contains_key(key) { error!( "Key '{key}' already exists in the vault. Use a different key or delete the existing one first." @@ -96,11 +99,12 @@ impl SecretProvider for LocalProvider { vault.insert(key.to_string(), envelope); - confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault") + store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> { - let mut vault: HashMap = confy::load("gman", "vault").unwrap_or_default(); + let vault_path = active_vault_path(config)?; + let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let password = get_password(config)?; let envelope = encrypt_string(&password, value)?; @@ -113,27 +117,29 @@ impl SecretProvider for LocalProvider { .with_context(|| format!("key '{key}' not found in the vault"))?; *vault_entry = envelope; - return confy::store("gman", "vault", vault) + return store_vault(&vault_path, &vault) .with_context(|| "failed to save secret to the vault"); } vault.insert(key.to_string(), envelope); - confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault") + store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } - fn delete_secret(&self, key: &str) -> Result<()> { - let mut vault: HashMap = confy::load("gman", "vault").unwrap_or_default(); + fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()> { + let vault_path = active_vault_path(config)?; + let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); if !vault.contains_key(key) { error!("Key '{key}' does not exist in the vault."); bail!("key '{key}' does not exist"); } vault.remove(key); - confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault") + store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } - fn list_secrets(&self) -> Result> { - let vault: HashMap = confy::load("gman", "vault").unwrap_or_default(); + fn list_secrets(&self, config: &ProviderConfig) -> Result> { + let vault_path = active_vault_path(config)?; + let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let keys: Vec = vault.keys().cloned().collect(); Ok(keys) @@ -190,6 +196,54 @@ impl SecretProvider for LocalProvider { } } +fn default_vault_path() -> Result { + confy::get_configuration_file_path("gman", "vault").with_context(|| "get config dir") +} + +fn base_config_dir() -> Result { + default_vault_path()? + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| anyhow!("Failed to determine config dir")) +} + +fn repo_dir_for_config(config: &ProviderConfig) -> Result> { + if let Some(remote) = &config.git_remote_url { + let name = repo_name_from_url(remote); + let dir = base_config_dir()?.join(format!(".{}", name)); + Ok(Some(dir)) + } else { + Ok(None) + } +} + +fn active_vault_path(config: &ProviderConfig) -> Result { + if let Some(dir) = repo_dir_for_config(config)? + && dir.exists() + { + return Ok(dir.join("vault.yml")); + } + + default_vault_path() +} + +fn load_vault(path: &Path) -> Result> { + if !path.exists() { + return Ok(HashMap::new()); + } + let s = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let map: HashMap = serde_yaml::from_str(&s).unwrap_or_default(); + Ok(map) +} + +fn store_vault(path: &Path, map: &HashMap) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let s = serde_yaml::to_string(map).with_context(|| "serialize vault")?; + fs::write(path, s).with_context(|| format!("write {}", path.display())) +} + fn encrypt_string(password: &SecretString, plaintext: &str) -> Result { let mut salt = [0u8; SALT_LEN]; OsRng.fill_bytes(&mut salt); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 5eb4fb8..0234f5c 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -34,8 +34,8 @@ pub trait SecretProvider { self.name() )) } - fn delete_secret(&self, key: &str) -> Result<()>; - fn list_secrets(&self) -> Result> { + fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()>; + fn list_secrets(&self, _config: &ProviderConfig) -> Result> { Err(anyhow!( "list secrets is not supported for the provider {}", self.name() diff --git a/tests/prop_crypto.rs b/tests/prop_crypto.rs index 9b21aa7..9110a72 100644 --- a/tests/prop_crypto.rs +++ b/tests/prop_crypto.rs @@ -1,11 +1,15 @@ use base64::Engine; use gman::{decrypt_string, encrypt_string}; use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] +} use secrecy::SecretString; proptest! { #[test] - fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,2048}") { + fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,512}") { let pw = SecretString::new(password.into()); let env = encrypt_string(pw.clone(), &msg).unwrap(); let out = decrypt_string(pw, &env).unwrap(); @@ -14,7 +18,7 @@ proptest! { } #[test] - fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,256}") { + fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,128}") { let pw = SecretString::new(password.into()); let env = encrypt_string(pw.clone(), &msg).unwrap(); // Flip a bit in the ct payload segment