use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{Result, VaultError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KmsBackend {
Env,
File,
AwsSecretsManager,
AzureKeyVault,
HashicorpVault,
}
impl KmsBackend {
#[must_use]
pub fn scheme(self) -> &'static str {
match self {
KmsBackend::Env => "env",
KmsBackend::File => "file",
KmsBackend::AwsSecretsManager => "aws-sm",
KmsBackend::AzureKeyVault => "azure-kv",
KmsBackend::HashicorpVault => "vault",
}
}
}
impl std::fmt::Display for KmsBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
KmsBackend::Env => "env",
KmsBackend::File => "file",
KmsBackend::AwsSecretsManager => "aws-secrets-manager",
KmsBackend::AzureKeyVault => "azure-key-vault",
KmsBackend::HashicorpVault => "hashicorp-vault",
};
write!(f, "{s}")
}
}
impl std::str::FromStr for KmsBackend {
type Err = VaultError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().replace('_', "-").as_str() {
"env" | "environment" => Ok(KmsBackend::Env),
"file" | "path" => Ok(KmsBackend::File),
"aws" | "aws-sm" | "aws-secrets-manager" => Ok(KmsBackend::AwsSecretsManager),
"azure" | "azure-kv" | "azure-key-vault" => Ok(KmsBackend::AzureKeyVault),
"hashicorp" | "hashicorp-vault" | "vault" | "hcv" => Ok(KmsBackend::HashicorpVault),
_ => Err(VaultError::InvalidInput(format!(
"Unknown KMS backend: {s}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KmsUri {
pub backend: KmsBackend,
pub secret_id: String,
pub endpoint: Option<String>,
}
impl std::fmt::Display for KmsUri {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.backend, &self.endpoint) {
(KmsBackend::AzureKeyVault, Some(v)) => {
write!(f, "azure-kv://{}/{}", v, self.secret_id)
}
_ => write!(f, "{}://{}", self.backend.scheme(), self.secret_id),
}
}
}
impl std::str::FromStr for KmsUri {
type Err = VaultError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let (scheme, rest) = s.split_once("://").ok_or_else(|| {
VaultError::InvalidInput(format!(
"Not a KMS URI (expected `scheme://...`): {s}. \
Known schemes: env, file, aws-sm, azure-kv, vault"
))
})?;
let backend: KmsBackend = scheme.parse()?;
let rest = rest.trim();
if rest.is_empty() {
return Err(VaultError::InvalidInput(format!(
"KMS URI `{s}` has an empty secret reference"
)));
}
match backend {
KmsBackend::AzureKeyVault => {
let (vault, secret) = rest.split_once('/').ok_or_else(|| {
VaultError::InvalidInput(format!(
"Azure Key Vault URI must be `azure-kv://<vault>/<secret>`, got: {s}"
))
})?;
if vault.is_empty() || secret.is_empty() {
return Err(VaultError::InvalidInput(format!(
"Azure Key Vault URI must be `azure-kv://<vault>/<secret>`, got: {s}"
)));
}
Ok(KmsUri {
backend,
secret_id: secret.to_string(),
endpoint: Some(vault.to_string()),
})
}
KmsBackend::HashicorpVault => {
if !rest.contains('/') {
return Err(VaultError::InvalidInput(format!(
"HashiCorp Vault URI must be `vault://<mount>/<path>/<key>`, got: {s}"
)));
}
Ok(KmsUri {
backend,
secret_id: rest.to_string(),
endpoint: None,
})
}
KmsBackend::File => Ok(KmsUri {
backend,
secret_id: normalize_file_path(rest),
endpoint: None,
}),
KmsBackend::Env | KmsBackend::AwsSecretsManager => Ok(KmsUri {
backend,
secret_id: rest.to_string(),
endpoint: None,
}),
}
}
}
fn normalize_file_path(rest: &str) -> String {
let bytes = rest.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[2] == b':' && bytes[1].is_ascii_alphabetic() {
return rest[1..].to_string();
}
rest.to_string()
}
#[must_use]
pub fn is_kms_uri(s: &str) -> bool {
s.split_once("://")
.is_some_and(|(scheme, rest)| scheme.parse::<KmsBackend>().is_ok() && !rest.is_empty())
}
#[derive(Debug, Clone)]
pub struct KmsRequest {
pub backend: KmsBackend,
pub secret_id: String,
pub endpoint: Option<String>,
}
impl From<KmsRequest> for KmsUri {
fn from(r: KmsRequest) -> Self {
KmsUri {
backend: r.backend,
secret_id: r.secret_id,
endpoint: r.endpoint,
}
}
}
pub fn fetch(uri: &KmsUri) -> Result<Zeroizing<String>> {
match uri.backend {
KmsBackend::Env => fetch_from_env(&uri.secret_id),
KmsBackend::File => fetch_from_file(&uri.secret_id),
KmsBackend::AwsSecretsManager => fetch_from_aws(uri),
KmsBackend::AzureKeyVault => fetch_from_azure(uri),
KmsBackend::HashicorpVault => fetch_from_hashicorp(uri),
}
}
pub fn resolve(value: &str) -> Result<Zeroizing<String>> {
if is_kms_uri(value) {
let uri = value.parse::<KmsUri>()?;
crate::telemetry::track_feature("kms", Some(uri.backend.scheme()));
fetch(&uri)
} else {
Ok(Zeroizing::new(value.to_string()))
}
}
pub fn fetch_secret(req: &KmsRequest) -> Result<Zeroizing<String>> {
fetch(&KmsUri {
backend: req.backend,
secret_id: req.secret_id.clone(),
endpoint: req.endpoint.clone(),
})
}
#[must_use]
pub fn available_backends() -> Vec<KmsBackend> {
vec![
KmsBackend::Env,
KmsBackend::File,
KmsBackend::AwsSecretsManager,
KmsBackend::AzureKeyVault,
KmsBackend::HashicorpVault,
]
}
fn fetch_from_env(var_name: &str) -> Result<Zeroizing<String>> {
std::env::var(var_name)
.map(Zeroizing::new)
.map_err(|_| VaultError::ConfigError(format!("Environment variable '{var_name}' not set")))
}
fn fetch_from_file(path: &str) -> Result<Zeroizing<String>> {
let path = std::path::Path::new(path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path)?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(VaultError::SecurityViolation(format!(
"Secret file {} is readable by group/other (mode {:o}); chmod 600 it",
path.display(),
mode
)));
}
}
let contents = std::fs::read_to_string(path)?;
Ok(Zeroizing::new(
contents.trim_end_matches(['\n', '\r']).to_string(),
))
}
fn fetch_from_hashicorp(uri: &KmsUri) -> Result<Zeroizing<String>> {
let addr = uri
.endpoint
.clone()
.or_else(|| std::env::var("VAULT_ADDR").ok())
.ok_or_else(|| {
VaultError::ConfigError(
"HashiCorp Vault address not set — export VAULT_ADDR (e.g. https://vault:8200)"
.to_string(),
)
})?;
let token = std::env::var("VAULT_TOKEN").map_err(|_| {
VaultError::ConfigError("VAULT_TOKEN not set — required for HashiCorp Vault".to_string())
})?;
let (secret_path, field) = uri.secret_id.rsplit_once('/').ok_or_else(|| {
VaultError::InvalidInput(format!(
"HashiCorp Vault path must include a field: vault://<mount>/<path>/<key>, got {}",
uri.secret_id
))
})?;
let (mount, rest) = secret_path.split_once('/').unwrap_or((secret_path, ""));
let addr = addr.trim_end_matches('/');
let v2 = if rest.is_empty() {
format!("{addr}/v1/{mount}/data")
} else {
format!("{addr}/v1/{mount}/data/{rest}")
};
let v1 = format!("{addr}/v1/{secret_path}");
let client = http_client()?;
let mut body: Option<serde_json::Value> = None;
for url in [v2, v1] {
let resp = client
.get(&url)
.header("X-Vault-Token", &token)
.send()
.map_err(|e| VaultError::ConfigError(format!("HashiCorp Vault request failed: {e}")))?;
if resp.status().as_u16() == 404 {
continue;
}
if !resp.status().is_success() {
return Err(VaultError::ConfigError(format!(
"HashiCorp Vault returned {} for {url}",
resp.status()
)));
}
body = Some(resp.json().map_err(|e| {
VaultError::SerializationError(format!("Invalid JSON from HashiCorp Vault: {e}"))
})?);
break;
}
let body = body.ok_or_else(|| {
VaultError::ConfigError(format!(
"Secret '{secret_path}' not found in HashiCorp Vault (tried KV v2 and v1)"
))
})?;
let data = body
.get("data")
.and_then(|d| d.get("data").or(Some(d)))
.ok_or_else(|| {
VaultError::ConfigError("HashiCorp Vault response had no `data` object".to_string())
})?;
let value = data
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
VaultError::ConfigError(format!(
"Field '{field}' not present in HashiCorp Vault secret '{secret_path}'"
))
})?;
Ok(Zeroizing::new(value.to_string()))
}
fn fetch_from_azure(uri: &KmsUri) -> Result<Zeroizing<String>> {
let vault = uri.endpoint.as_deref().ok_or_else(|| {
VaultError::InvalidInput("Azure Key Vault URI must name a vault".to_string())
})?;
let token = std::env::var("AZURE_KEYVAULT_TOKEN")
.or_else(|_| std::env::var("AZURE_ACCESS_TOKEN"))
.map_err(|_| {
VaultError::ConfigError(
"AZURE_KEYVAULT_TOKEN not set — mint one with \
`az account get-access-token --resource https://vault.azure.net \
--query accessToken -o tsv`"
.to_string(),
)
})?;
let host = if vault.contains('.') {
vault.to_string()
} else {
format!("{vault}.vault.azure.net")
};
let url = format!(
"https://{host}/secrets/{}?api-version=7.4",
uri.secret_id.trim_start_matches('/')
);
let resp = http_client()?
.get(&url)
.bearer_auth(&token)
.send()
.map_err(|e| VaultError::ConfigError(format!("Azure Key Vault request failed: {e}")))?;
if !resp.status().is_success() {
return Err(VaultError::ConfigError(format!(
"Azure Key Vault returned {} for secret '{}'",
resp.status(),
uri.secret_id
)));
}
let body: serde_json::Value = resp.json().map_err(|e| {
VaultError::SerializationError(format!("Invalid JSON from Azure Key Vault: {e}"))
})?;
let value = body
.get("value")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
VaultError::ConfigError(format!(
"Azure Key Vault secret '{}' had no `value` field",
uri.secret_id
))
})?;
Ok(Zeroizing::new(value.to_string()))
}
#[cfg(feature = "s3")]
fn fetch_from_aws(uri: &KmsUri) -> Result<Zeroizing<String>> {
let secret_id = uri.secret_id.clone();
let region_override = uri
.endpoint
.clone()
.or_else(|| std::env::var("AWS_REGION").ok());
let fetch = async move {
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(region) = region_override {
loader = loader.region(aws_sdk_secretsmanager::config::Region::new(region));
}
let config = loader.load().await;
let client = aws_sdk_secretsmanager::Client::new(&config);
let out = client
.get_secret_value()
.secret_id(&secret_id)
.send()
.await
.map_err(|e| {
VaultError::ConfigError(format!(
"AWS Secrets Manager GetSecretValue failed for '{secret_id}': {e}"
))
})?;
if let Some(s) = out.secret_string() {
return Ok(Zeroizing::new(s.to_string()));
}
if let Some(blob) = out.secret_binary() {
return String::from_utf8(blob.as_ref().to_vec())
.map(Zeroizing::new)
.map_err(|_| {
VaultError::ConfigError(format!(
"AWS secret '{secret_id}' is binary and not valid UTF-8"
))
});
}
Err(VaultError::ConfigError(format!(
"AWS secret '{secret_id}' has neither a string nor a binary value"
)))
};
block_on(fetch)
}
#[cfg(not(feature = "s3"))]
fn fetch_from_aws(uri: &KmsUri) -> Result<Zeroizing<String>> {
Err(VaultError::ConfigError(format!(
"AWS Secrets Manager support is not compiled in (secret: {}). \
Rebuild with `--features s3`.",
uri.secret_id
)))
}
#[cfg(feature = "s3")]
fn block_on<F: std::future::Future<Output = Result<Zeroizing<String>>>>(
fut: F,
) -> Result<Zeroizing<String>> {
match tokio::runtime::Handle::try_current() {
Ok(handle) => tokio::task::block_in_place(|| handle.block_on(fut)),
Err(_) => tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(VaultError::IoError)?
.block_on(fut),
}
}
fn http_client() -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| VaultError::ConfigError(format!("Failed to build HTTP client: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_parse() {
assert_eq!("env".parse::<KmsBackend>().unwrap(), KmsBackend::Env);
assert_eq!("file".parse::<KmsBackend>().unwrap(), KmsBackend::File);
assert_eq!(
"aws".parse::<KmsBackend>().unwrap(),
KmsBackend::AwsSecretsManager
);
assert_eq!(
"azure-key-vault".parse::<KmsBackend>().unwrap(),
KmsBackend::AzureKeyVault
);
assert_eq!(
"hashicorp".parse::<KmsBackend>().unwrap(),
KmsBackend::HashicorpVault
);
assert!("unknown".parse::<KmsBackend>().is_err());
}
#[test]
fn test_backend_scheme_roundtrip() {
for b in available_backends() {
assert_eq!(b.scheme().parse::<KmsBackend>().unwrap(), b);
}
}
#[test]
fn test_uri_parse_env() {
let uri: KmsUri = "env://MY_SECRET".parse().unwrap();
assert_eq!(uri.backend, KmsBackend::Env);
assert_eq!(uri.secret_id, "MY_SECRET");
assert_eq!(uri.to_string(), "env://MY_SECRET");
}
#[test]
fn test_uri_parse_file_posix_and_windows() {
let posix: KmsUri = "file:///etc/aim/pass".parse().unwrap();
assert_eq!(posix.secret_id, "/etc/aim/pass");
let windows: KmsUri = "file:///c:/secrets/pass.txt".parse().unwrap();
assert_eq!(windows.secret_id, "c:/secrets/pass.txt");
let relative: KmsUri = "file://./pass.txt".parse().unwrap();
assert_eq!(relative.secret_id, "./pass.txt");
}
#[test]
fn test_uri_parse_azure() {
let uri: KmsUri = "azure-kv://my-vault/hmac-key".parse().unwrap();
assert_eq!(uri.backend, KmsBackend::AzureKeyVault);
assert_eq!(uri.endpoint.as_deref(), Some("my-vault"));
assert_eq!(uri.secret_id, "hmac-key");
assert_eq!(uri.to_string(), "azure-kv://my-vault/hmac-key");
assert!("azure-kv://only-vault".parse::<KmsUri>().is_err());
}
#[test]
fn test_uri_parse_hashicorp() {
let uri: KmsUri = "vault://secret/aim/passphrase".parse().unwrap();
assert_eq!(uri.backend, KmsBackend::HashicorpVault);
assert_eq!(uri.secret_id, "secret/aim/passphrase");
assert!("vault://nofield".parse::<KmsUri>().is_err());
}
#[test]
fn test_uri_parse_aws() {
let uri: KmsUri = "aws-sm://prod/aim-passphrase".parse().unwrap();
assert_eq!(uri.backend, KmsBackend::AwsSecretsManager);
assert_eq!(uri.secret_id, "prod/aim-passphrase");
}
#[test]
fn test_uri_parse_rejects_non_uri() {
assert!("hunter2".parse::<KmsUri>().is_err());
assert!("ftp://host/x".parse::<KmsUri>().is_err());
assert!("env://".parse::<KmsUri>().is_err());
}
#[test]
fn test_is_kms_uri() {
assert!(is_kms_uri("env://X"));
assert!(is_kms_uri("vault://secret/a/b"));
assert!(!is_kms_uri("hunter2"));
assert!(!is_kms_uri("env://"));
assert!(!is_kms_uri("https://example.com"));
}
#[test]
fn test_fetch_from_env() {
std::env::set_var("AIM_TEST_SECRET_KMS", "super-secret-passphrase");
let uri: KmsUri = "env://AIM_TEST_SECRET_KMS".parse().unwrap();
assert_eq!(&*fetch(&uri).unwrap(), "super-secret-passphrase");
std::env::remove_var("AIM_TEST_SECRET_KMS");
}
#[test]
fn test_env_missing() {
let uri: KmsUri = "env://AIM_DEFINITELY_NOT_SET_42".parse().unwrap();
assert!(fetch(&uri).is_err());
}
#[test]
fn test_fetch_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pass.txt");
std::fs::write(&path, "file-passphrase\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let uri = KmsUri {
backend: KmsBackend::File,
secret_id: path.to_string_lossy().to_string(),
endpoint: None,
};
assert_eq!(&*fetch(&uri).unwrap(), "file-passphrase");
}
#[cfg(unix)]
#[test]
fn test_file_rejects_loose_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("loose.txt");
std::fs::write(&path, "secret").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let uri = KmsUri {
backend: KmsBackend::File,
secret_id: path.to_string_lossy().to_string(),
endpoint: None,
};
assert!(fetch(&uri).is_err());
}
#[test]
fn test_file_missing() {
let uri: KmsUri = "file:///definitely/not/here/aim-secret".parse().unwrap();
assert!(fetch(&uri).is_err());
}
#[test]
fn test_resolve_passes_through_literals() {
assert_eq!(&*resolve("hunter2").unwrap(), "hunter2");
}
#[test]
fn test_resolve_follows_uri() {
std::env::set_var("AIM_TEST_RESOLVE", "from-env");
assert_eq!(&*resolve("env://AIM_TEST_RESOLVE").unwrap(), "from-env");
std::env::remove_var("AIM_TEST_RESOLVE");
}
#[test]
fn test_hashicorp_requires_config() {
std::env::remove_var("VAULT_ADDR");
std::env::remove_var("VAULT_TOKEN");
let uri: KmsUri = "vault://secret/aim/pass".parse().unwrap();
assert!(fetch(&uri).is_err());
}
#[test]
fn test_azure_requires_token() {
std::env::remove_var("AZURE_KEYVAULT_TOKEN");
std::env::remove_var("AZURE_ACCESS_TOKEN");
let uri: KmsUri = "azure-kv://my-vault/secret".parse().unwrap();
assert!(fetch(&uri).is_err());
}
#[cfg(not(feature = "s3"))]
#[test]
fn test_aws_reports_missing_feature() {
let uri: KmsUri = "aws-sm://prod/pass".parse().unwrap();
let err = fetch(&uri).unwrap_err().to_string();
assert!(err.contains("--features s3"), "unexpected error: {err}");
}
#[test]
fn test_legacy_request_api() {
std::env::set_var("AIM_TEST_LEGACY", "legacy-value");
let req = KmsRequest {
backend: KmsBackend::Env,
secret_id: "AIM_TEST_LEGACY".into(),
endpoint: None,
};
assert_eq!(&*fetch_secret(&req).unwrap(), "legacy-value");
std::env::remove_var("AIM_TEST_LEGACY");
}
#[test]
fn test_available_backends() {
assert_eq!(available_backends().len(), 5);
}
}