diff --git a/README.md b/README.md index eed63ef..88c9444 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ documented and added without breaking existing setups. The following table shows | [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | | | [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | ✅ | [Azure Key Vault](#provider-azure_key_vault) | | | [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | ✅ | [GCP Secret Manager](#provider-gcp_secret_manager) | | +| [`gopass`](https://www.gopass.pw/) | ✅ | | | | [`1password`](https://1password.com/) | 🕒 | | | | [`bitwarden`](https://bitwarden.com/) | 🕒 | | | | [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets | @@ -405,6 +406,26 @@ Important notes: - Ensure your identity has the necessary Key Vault permissions (RBAC such as `Key Vault Secrets User`/`Administrator`, or appropriate access policies) for get/set/list/delete. +### Provider: `gopass` +The `gopass` provider uses [gopass](https://www.gopass.pw/) as the backing storage location for secrets. + +- Optional: `store` (string) to specify a particular gopass store if you have multiple. + +Configuration example: + +```yaml +default_provider: gopass +providers: + - name: gopass + type: gopass + store: my-store # Optional; if omitted, uses the default configured gopass store +``` + +Important notes: +- Ensure `gopass` is installed and initialized on your system. +- Secrets are managed using gopass's native commands; `gman` acts as a wrapper to interface with gopass. +- Updates overwrite existing secrets +- If no store is specified, the default gopass store is used and `gman sync` will sync with all configured stores. ## Run Configurations Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are diff --git a/src/bin/gman/cli.rs b/src/bin/gman/cli.rs index 0e2c201..649bf62 100644 --- a/src/bin/gman/cli.rs +++ b/src/bin/gman/cli.rs @@ -16,7 +16,7 @@ const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}"; const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; pub async fn wrap_and_run_command( - provider: Option, + provider: Option, config: &Config, tokens: Vec, profile_name: Option, @@ -37,8 +37,9 @@ pub async fn wrap_and_run_command( .find(|c| c.name.as_deref() == Some(run_config_profile_name)) }); if let Some(run_cfg) = run_config_opt { - let mut provider_config = config.extract_provider_config(provider.or(run_cfg.provider.clone()))?; - let secrets_provider = provider_config.extract_provider(); + let mut provider_config = + config.extract_provider_config(provider.or(run_cfg.provider.clone()))?; + let secrets_provider = provider_config.extract_provider(); let secrets_result_futures = run_cfg .secrets .as_ref() @@ -318,7 +319,7 @@ mod tests { let run_config = RunConfig { name: Some("test".to_string()), - provider: None, + provider: None, secrets: Some(vec!["testing/SOME-secret".to_string()]), files: Some(vec![file_path.clone()]), flag: None, @@ -338,7 +339,7 @@ mod tests { fn test_parse_args_insert_and_append() { let run_config = RunConfig { name: Some("docker".into()), - provider: None, + provider: None, secrets: Some(vec!["api_key".into()]), files: None, flag: Some("-e".into()), @@ -389,7 +390,7 @@ mod tests { // Create a config with a matching run profile for command "echo" let run_cfg = RunConfig { name: Some("echo".into()), - provider: None, + provider: None, secrets: Some(vec!["api_key".into()]), files: None, flag: None, diff --git a/src/config.rs b/src/config.rs index 7dd4a16..21c43ad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,7 +45,7 @@ use validator::{Validate, ValidationError}; pub struct RunConfig { #[validate(required)] pub name: Option, - pub provider: Option, + pub provider: Option, #[validate(required)] pub secrets: Option>, pub files: Option>, @@ -162,6 +162,10 @@ impl ProviderConfig { debug!("Using Azure Key Vault provider"); provider_def } + SupportedProvider::Gopass { provider_def } => { + debug!("Using Gopass provider"); + provider_def + } } } } diff --git a/src/providers/gopass.rs b/src/providers/gopass.rs new file mode 100644 index 0000000..8f4b68e --- /dev/null +++ b/src/providers/gopass.rs @@ -0,0 +1,190 @@ +use crate::providers::{ENV_PATH, SecretProvider}; +use anyhow::{Context, Result, anyhow}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use validator::Validate; + +#[skip_serializing_none] +/// Gopass-based secret provider +/// See [Gopass](https://gopass.pw/) for more information. +/// +/// You must already have gopass installed and configured on your system. +/// +/// This provider stores secrets in a gopass store. It requires +/// an optional store name to be specified. If no store name is +/// specified, the default store will be used. +/// +/// Example +/// ```no_run +/// use gman::providers::local::GopassProvider; +/// use gman::providers::{SecretProvider, SupportedProvider}; +/// use gman::config::Config; +/// +/// let provider = GopassProvider::default(); +/// let _ = provider.set_secret("MY_SECRET", "value"); +/// ``` +#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct GopassProvider { + pub store: Option, +} + +#[async_trait::async_trait] +impl SecretProvider for GopassProvider { + fn name(&self) -> &'static str { + "GopassProvider" + } + + async fn get_secret(&self, key: &str) -> Result { + ensure_gopass_installed()?; + + let mut child = Command::new("gopass") + .args(["show", "-yfon", key]) + .env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set")) + .stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .context("Failed to spawn gopass command")?; + + let mut output = String::new(); + child + .stdout + .as_mut() + .expect("Failed to open gopass stdout") + .read_to_string(&mut output) + .context("Failed to read gopass output")?; + + let status = child.wait().context("Failed to wait on gopass process")?; + if !status.success() { + return Err(anyhow!("gopass command failed with status: {}", status)); + } + + Ok(output.trim_end_matches(&['\r', '\n'][..]).to_string()) + } + + async fn set_secret(&self, key: &str, value: &str) -> Result<()> { + ensure_gopass_installed()?; + + let mut child = Command::new("gopass") + .args(["insert", "-f", key]) + .env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set")) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("Failed to spawn gopass command")?; + + { + let stdin = child.stdin.as_mut().expect("Failed to open gopass stdin"); + stdin + .write_all(value.as_bytes()) + .context("Failed to write to gopass stdin")?; + } + + let status = child.wait().context("Failed to wait on gopass process")?; + if !status.success() { + return Err(anyhow!("gopass command failed with status: {}", status)); + } + + Ok(()) + } + + async fn update_secret(&self, key: &str, value: &str) -> Result<()> { + ensure_gopass_installed()?; + + self.set_secret(key, value).await + } + + async fn delete_secret(&self, key: &str) -> Result<()> { + ensure_gopass_installed()?; + + let mut child = Command::new("gopass") + .args(["rm", "-f", key]) + .env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set")) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("Failed to spawn gopass command")?; + + let status = child.wait().context("Failed to wait on gopass process")?; + if !status.success() { + return Err(anyhow!("gopass command failed with status: {}", status)); + } + + Ok(()) + } + + async fn list_secrets(&self) -> Result> { + ensure_gopass_installed()?; + + let mut child = Command::new("gopass") + .args(["ls", "-f"]) + .env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set")) + .stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .context("Failed to spawn gopass command")?; + + let mut output = String::new(); + child + .stdout + .as_mut() + .expect("Failed to open gopass stdout") + .read_to_string(&mut output) + .context("Failed to read gopass output")?; + + let status = child.wait().context("Failed to wait on gopass process")?; + if !status.success() { + return Err(anyhow!("gopass command failed with status: {}", status)); + } + + let secrets: Vec = output + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + + Ok(secrets) + } + + async fn sync(&mut self) -> Result<()> { + ensure_gopass_installed()?; + let mut child = Command::new("gopass"); + child.arg("sync"); + + if let Some(store) = &self.store { + child.args(["-s", store]); + } + + let status = child + .env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set")) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("Failed to spawn gopass command")? + .wait() + .context("Failed to wait on gopass process")?; + + if !status.success() { + return Err(anyhow!("gopass command failed with status: {}", status)); + } + + Ok(()) + } +} + +fn ensure_gopass_installed() -> Result<()> { + if which::which("gopass").is_err() { + Err(anyhow!( + "Gopass is not installed or not found in PATH. Please install Gopass from https://gopass.pw/" + )) + } else { + Ok(()) + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 5525e45..262a743 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -6,17 +6,24 @@ pub mod aws_secrets_manager; pub mod azure_key_vault; pub mod gcp_secret_manager; mod git_sync; +mod gopass; pub mod local; +use crate::providers::gopass::GopassProvider; use crate::providers::local::LocalProvider; -use anyhow::{Result, anyhow}; +use anyhow::{Context, Result, anyhow}; use aws_secrets_manager::AwsSecretsManagerProvider; +use azure_key_vault::AzureKeyVaultProvider; use gcp_secret_manager::GcpSecretManagerProvider; +use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use std::fmt; use std::fmt::{Display, Formatter}; +use std::{env, fmt}; use validator::{Validate, ValidationErrors}; +pub(in crate::providers) static ENV_PATH: Lazy> = + Lazy::new(|| env::var("PATH").context("No PATH environment variable")); + /// A secret storage backend capable of CRUD, with optional /// update, listing, and sync support. #[async_trait::async_trait] @@ -63,7 +70,11 @@ pub enum SupportedProvider { }, AzureKeyVault { #[serde(flatten)] - provider_def: azure_key_vault::AzureKeyVaultProvider, + provider_def: AzureKeyVaultProvider, + }, + Gopass { + #[serde(flatten)] + provider_def: GopassProvider, }, } @@ -74,6 +85,7 @@ impl Validate for SupportedProvider { SupportedProvider::AwsSecretsManager { provider_def } => provider_def.validate(), SupportedProvider::GcpSecretManager { provider_def } => provider_def.validate(), SupportedProvider::AzureKeyVault { provider_def } => provider_def.validate(), + SupportedProvider::Gopass { provider_def } => provider_def.validate(), } } } @@ -93,6 +105,7 @@ impl Display for SupportedProvider { SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"), SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"), SupportedProvider::AzureKeyVault { .. } => write!(f, "azure_key_vault"), + SupportedProvider::Gopass { .. } => write!(f, "gopass"), } } }