use dialoguer::{Input, Select};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretsBackendChoice {
Keyring { service: String },
Aws {
secret_name: String,
region: Option<String>,
},
Gcp {
project: String,
secret_name: String,
},
Azure {
vault_url: String,
secret_name: String,
},
InlineConfig,
Plaintext,
}
#[derive(Debug, Clone, Default)]
pub struct AvailableBackends {
pub keyring: bool,
pub aws: bool,
pub gcp: bool,
pub azure: bool,
pub inline_config: bool,
pub plaintext: bool,
}
impl AvailableBackends {
fn any(&self) -> bool {
self.keyring || self.aws || self.gcp || self.azure || self.inline_config || self.plaintext
}
}
#[allow(clippy::type_complexity)]
pub struct BackendResolvers<'a> {
pub aws: Option<Box<dyn FnOnce() -> Result<(String, Option<String>), SecretsPromptError> + 'a>>,
pub gcp: Option<Box<dyn FnOnce() -> Result<(String, String), SecretsPromptError> + 'a>>,
pub azure: Option<Box<dyn FnOnce() -> Result<(String, String), SecretsPromptError> + 'a>>,
}
impl<'a> BackendResolvers<'a> {
pub fn empty() -> Self {
Self {
aws: None,
gcp: None,
azure: None,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecretsPromptError {
#[error("no backends configured — service must enable at least one")]
NoBackendsAvailable,
#[error("user aborted prompt: {0}")]
Dialoguer(#[from] dialoguer::Error),
#[error("{0}")]
Inner(String),
}
pub fn configure_secrets(
available: &AvailableBackends,
keyring_default_service: &str,
resolvers: BackendResolvers<'_>,
) -> Result<SecretsBackendChoice, SecretsPromptError> {
if !available.any() {
return Err(SecretsPromptError::NoBackendsAvailable);
}
let mut labels: Vec<&str> = Vec::new();
let mut tags: Vec<&str> = Vec::new();
if available.aws {
labels.push("AWS Secrets Manager");
tags.push("aws");
}
if available.gcp {
labels.push("GCP Secret Manager");
tags.push("gcp");
}
if available.azure {
labels.push("Azure Key Vault");
tags.push("azure");
}
if available.inline_config {
labels.push("Config file (hex-encoded seed in config.toml)");
tags.push("inline-config");
}
if available.keyring {
labels.push("OS keyring");
tags.push("keyring");
}
if available.plaintext {
labels.push("Plaintext file (NOT recommended)");
tags.push("plaintext");
}
let choice = if labels.len() == 1 {
0
} else {
Select::new()
.with_prompt("Seed storage backend")
.items(&labels)
.default(0)
.interact()?
};
match tags[choice] {
"keyring" => prompt_keyring(keyring_default_service),
"aws" => match resolvers.aws {
Some(r) => r().map(|(secret_name, region)| SecretsBackendChoice::Aws {
secret_name,
region,
}),
None => prompt_aws_fallback(),
},
"gcp" => match resolvers.gcp {
Some(r) => r().map(|(project, secret_name)| SecretsBackendChoice::Gcp {
project,
secret_name,
}),
None => prompt_gcp_fallback(),
},
"azure" => match resolvers.azure {
Some(r) => r().map(|(vault_url, secret_name)| SecretsBackendChoice::Azure {
vault_url,
secret_name,
}),
None => prompt_azure_fallback(),
},
"inline-config" => Ok(SecretsBackendChoice::InlineConfig),
"plaintext" => {
print_plaintext_warning();
Ok(SecretsBackendChoice::Plaintext)
}
other => Err(SecretsPromptError::Inner(format!(
"internal: unknown backend tag '{other}'"
))),
}
}
fn prompt_keyring(default_service: &str) -> Result<SecretsBackendChoice, SecretsPromptError> {
let service: String = Input::new()
.with_prompt("Keyring service name (use a unique name per instance)")
.default(default_service.to_string())
.interact_text()?;
Ok(SecretsBackendChoice::Keyring { service })
}
fn prompt_aws_fallback() -> Result<SecretsBackendChoice, SecretsPromptError> {
let region: String = Input::new()
.with_prompt("AWS region (leave empty for SDK default)")
.allow_empty(true)
.interact_text()?;
let region = if region.is_empty() {
None
} else {
Some(region)
};
let secret_name: String = Input::new()
.with_prompt("AWS Secrets Manager secret name")
.interact_text()?;
Ok(SecretsBackendChoice::Aws {
secret_name,
region,
})
}
fn prompt_gcp_fallback() -> Result<SecretsBackendChoice, SecretsPromptError> {
let project: String = Input::new().with_prompt("GCP project ID").interact_text()?;
let secret_name: String = Input::new()
.with_prompt("GCP Secret Manager secret name")
.interact_text()?;
Ok(SecretsBackendChoice::Gcp {
project,
secret_name,
})
}
fn prompt_azure_fallback() -> Result<SecretsBackendChoice, SecretsPromptError> {
let vault_url: String = Input::new()
.with_prompt("Azure Key Vault URL (e.g. https://my-vault.vault.azure.net)")
.interact_text()?;
let secret_name: String = Input::new()
.with_prompt("Azure Key Vault secret name")
.interact_text()?;
Ok(SecretsBackendChoice::Azure {
vault_url,
secret_name,
})
}
fn print_plaintext_warning() {
eprintln!();
eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════╗");
eprintln!("║ WARNING: Plaintext storage is NOT secure. ║");
eprintln!("║ Seeds will be stored in a plaintext file on disk. ║");
eprintln!("║ Use only for development or testing. ║");
eprintln!("╚══════════════════════════════════════════════════════════╝\x1b[0m");
eprintln!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_backends_available_errors() {
let available = AvailableBackends::default();
let err = configure_secrets(&available, "vtc", BackendResolvers::empty())
.expect_err("must error when no backends advertised");
assert!(matches!(err, SecretsPromptError::NoBackendsAvailable));
}
#[test]
fn available_backends_any_is_true_when_keyring_is_set() {
let available = AvailableBackends {
keyring: true,
..Default::default()
};
assert!(available.any());
}
#[test]
fn available_backends_any_is_false_by_default() {
assert!(!AvailableBackends::default().any());
}
}