#[cfg(feature = "setup-wizard")]
pub mod wizard;
pub use vti_secrets::SecretBackend;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use serde::{Deserialize, Serialize};
use vti_secrets::SeedStore;
use vti_secrets::seed_store::PlaintextSeedStore;
use crate::server::config::SecretsConfig;
use crate::server::error::AppError;
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Clone, Serialize, Deserialize)]
pub struct ServerSecrets {
pub signing_key: String,
pub key_agreement_key: String,
pub jwt_signing_key: String,
#[serde(default)]
pub vta_credential: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retired: Vec<RetiredKeys>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct RetiredKeys {
pub ka_kid: String,
pub key_agreement_key: String,
pub signing_kid: String,
pub signing_key: String,
}
impl std::fmt::Debug for RetiredKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RetiredKeys")
.field("ka_kid", &self.ka_kid)
.field("key_agreement_key", &"<redacted>")
.field("signing_kid", &self.signing_kid)
.field("signing_key", &"<redacted>")
.finish()
}
}
impl std::fmt::Debug for ServerSecrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerSecrets")
.field("signing_key", &"<redacted>")
.field("key_agreement_key", &"<redacted>")
.field("jwt_signing_key", &"<redacted>")
.field(
"vta_credential",
&self.vta_credential.as_ref().map(|_| "<redacted>"),
)
.field("retired", &self.retired)
.finish()
}
}
#[derive(Default, Clone, Serialize, Deserialize)]
pub struct StoredSecrets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<ServerSecrets>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap_seed: Option<String>,
}
impl std::fmt::Debug for StoredSecrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StoredSecrets")
.field("secrets", &self.secrets)
.field(
"bootstrap_seed",
&self.bootstrap_seed.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
impl StoredSecrets {
fn from_bytes(bytes: &[u8]) -> Result<Self, AppError> {
serde_json::from_slice(bytes).map_err(|e| {
AppError::SecretStore(format!(
"the stored secrets envelope is not valid JSON: {e}"
))
})
}
fn to_bytes(&self) -> Result<Vec<u8>, AppError> {
serde_json::to_vec(self)
.map_err(|e| AppError::SecretStore(format!("failed to serialise secrets: {e}")))
}
fn encode_seed(seed: &[u8; 32]) -> String {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
B64.encode(seed)
}
fn decode_seed(b64: &str) -> Result<[u8; 32], AppError> {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
let bytes = B64.decode(b64.trim().as_bytes()).map_err(|e| {
AppError::SecretStore(format!("failed to base64-decode bootstrap seed: {e}"))
})?;
bytes.as_slice().try_into().map_err(|_| {
AppError::SecretStore(format!(
"bootstrap seed has {} bytes, expected 32",
bytes.len()
))
})
}
}
pub trait SecretStore: Send + Sync {
fn get(&self) -> BoxFuture<'_, Result<Option<ServerSecrets>, AppError>>;
fn set(&self, secrets: &ServerSecrets) -> BoxFuture<'_, Result<(), AppError>>;
fn get_bootstrap_seed(&self) -> BoxFuture<'_, Result<Option<[u8; 32]>, AppError>>;
fn set_bootstrap_seed(&self, seed: &[u8; 32]) -> BoxFuture<'_, Result<(), AppError>>;
fn clear_bootstrap_seed(&self) -> BoxFuture<'_, Result<(), AppError>>;
}
struct EnvelopeStore {
inner: Box<dyn SeedStore>,
}
impl EnvelopeStore {
async fn load(&self) -> Result<StoredSecrets, AppError> {
match self.inner.get().await.map_err(from_vti)? {
Some(bytes) => StoredSecrets::from_bytes(&bytes),
None => Ok(StoredSecrets::default()),
}
}
async fn save(&self, env: &StoredSecrets) -> Result<(), AppError> {
self.inner.set(&env.to_bytes()?).await.map_err(from_vti)
}
}
impl SecretStore for EnvelopeStore {
fn get(&self) -> BoxFuture<'_, Result<Option<ServerSecrets>, AppError>> {
Box::pin(async move { Ok(self.load().await?.secrets) })
}
fn set(&self, secrets: &ServerSecrets) -> BoxFuture<'_, Result<(), AppError>> {
let secrets = secrets.clone();
Box::pin(async move {
let mut env = self.load().await?;
env.secrets = Some(secrets);
self.save(&env).await
})
}
fn get_bootstrap_seed(&self) -> BoxFuture<'_, Result<Option<[u8; 32]>, AppError>> {
Box::pin(async move {
self.load()
.await?
.bootstrap_seed
.as_deref()
.map(StoredSecrets::decode_seed)
.transpose()
})
}
fn set_bootstrap_seed(&self, seed: &[u8; 32]) -> BoxFuture<'_, Result<(), AppError>> {
let encoded = StoredSecrets::encode_seed(seed);
Box::pin(async move {
let mut env = self.load().await?;
env.bootstrap_seed = Some(encoded);
self.save(&env).await
})
}
fn clear_bootstrap_seed(&self) -> BoxFuture<'_, Result<(), AppError>> {
Box::pin(async move {
let mut env = self.load().await?;
if env.bootstrap_seed.take().is_none() {
return Ok(());
}
self.save(&env).await
})
}
}
pub(crate) fn from_vti(e: vti_common::error::AppError) -> AppError {
match e {
vti_common::error::AppError::Config(msg) => AppError::Config(msg),
other => AppError::SecretStore(other.to_string()),
}
}
const KEYRING_USER: &str = "server_secrets";
pub fn resolve_backend(secrets: &SecretsConfig) -> SecretBackend {
if let Some(b) = secrets.backend {
return b;
}
if secrets.aws_secret_name.is_some() {
SecretBackend::Aws
} else if secrets.gcp_secret_name.is_some() {
SecretBackend::Gcp
} else if secrets.azure_secret_name.is_some() {
SecretBackend::Azure
} else if secrets.vault_addr.is_some() {
SecretBackend::Vault
} else if secrets.k8s_secret_name.is_some() {
SecretBackend::Kubernetes
} else {
SecretBackend::Keyring
}
}
pub fn is_plaintext_backend(secrets: &SecretsConfig) -> bool {
resolve_backend(secrets) == SecretBackend::Plaintext
}
pub fn backend_label(secrets: &SecretsConfig) -> &'static str {
match resolve_backend(secrets) {
SecretBackend::Keyring => "OS keyring",
SecretBackend::Aws => "AWS Secrets Manager",
SecretBackend::Gcp => "GCP Secret Manager",
SecretBackend::Azure => "Azure Key Vault",
SecretBackend::Vault => "HashiCorp Vault",
SecretBackend::Kubernetes => "Kubernetes Secret",
SecretBackend::Plaintext => "Plaintext file",
SecretBackend::ConfigSeed => "config_seed (unsupported)",
_ => "unsupported backend",
}
}
fn required(value: &Option<String>, backend: &str, field: &str) -> Result<String, AppError> {
value.clone().ok_or_else(|| {
AppError::Config(format!(
"secrets backend '{backend}' requires secrets.{field}"
))
})
}
pub fn to_vti_config(secrets: &SecretsConfig) -> Result<vti_secrets::SecretsConfig, AppError> {
let backend = resolve_backend(secrets);
let mut out = vti_secrets::SecretsConfig::default();
out.backend = Some(backend);
out.cache_ttl_secs = 0;
match backend {
SecretBackend::Keyring => {
out.keyring_service = secrets.keyring_service.clone();
}
SecretBackend::Aws => {
out.aws_secret_name = Some(required(
&secrets.aws_secret_name,
"aws",
"aws_secret_name",
)?);
out.aws_region = secrets.aws_region.clone();
}
SecretBackend::Gcp => {
out.gcp_secret_name = Some(required(
&secrets.gcp_secret_name,
"gcp",
"gcp_secret_name",
)?);
out.gcp_project = Some(required(&secrets.gcp_project, "gcp", "gcp_project")?);
}
SecretBackend::Azure => {
out.azure_vault_url = Some(required(
&secrets.azure_vault_url,
"azure",
"azure_vault_url",
)?);
out.azure_secret_name = Some(required(
&secrets.azure_secret_name,
"azure",
"azure_secret_name",
)?);
}
SecretBackend::Vault => {
out.vault_addr = Some(required(&secrets.vault_addr, "vault", "vault_addr")?);
out.vault_secret_path = Some(required(
&secrets.vault_secret_path,
"vault",
"vault_secret_path",
)?);
out.vault_kv_mount = secrets.vault_kv_mount.clone();
out.vault_secret_key = secrets.vault_secret_key.clone();
out.vault_namespace = secrets.vault_namespace.clone();
out.vault_auth_method = secrets.vault_auth_method.clone();
out.vault_k8s_role = secrets.vault_k8s_role.clone();
out.vault_k8s_mount = secrets.vault_k8s_mount.clone();
out.vault_k8s_jwt_path = secrets.vault_k8s_jwt_path.clone();
out.vault_token = secrets.vault_token.clone();
out.vault_approle_role_id = secrets.vault_approle_role_id.clone();
out.vault_approle_secret_id = secrets.vault_approle_secret_id.clone();
out.vault_approle_mount = secrets.vault_approle_mount.clone();
out.vault_skip_verify = secrets.vault_skip_verify;
}
SecretBackend::Kubernetes => {
out.k8s_secret_name = Some(required(
&secrets.k8s_secret_name,
"kubernetes",
"k8s_secret_name",
)?);
out.k8s_namespace = secrets.k8s_namespace.clone();
out.k8s_secret_key = secrets.k8s_secret_key.clone();
}
SecretBackend::Plaintext => {
if !secrets.confirm_plaintext {
return Err(AppError::Config(
"secrets.backend = \"plaintext\" stores the service's private keys in a \
clear-text file and is for tests only. Set secrets.confirm_plaintext = true \
to accept that, or choose a secure backend."
.into(),
));
}
out.allow_plaintext = true;
}
SecretBackend::ConfigSeed => {
return Err(AppError::Config(
"secrets.backend = \"config_seed\" is not supported: it would put the \
service's private keys in config.toml. Choose a secure backend."
.into(),
));
}
other => {
return Err(AppError::Config(format!(
"secrets.backend = {other:?} is not supported by this service. Choose one of \
keyring, aws, gcp, azure, vault, kubernetes."
)));
}
}
Ok(out)
}
pub(crate) fn no_secure_store(reason: &str) -> AppError {
AppError::SecretStore(format!(
"no secure secret store is available on this host: {reason}\n\
\n\
The service will not fall back to keeping its private keys in clear text. \
Configure one of these in [secrets] (or [secrets] backend in a setup recipe):\n\
\x20 - vault: HashiCorp Vault KV v2 (vault_addr, vault_secret_path)\n\
\x20 - k8s: a Kubernetes Secret (k8s_secret_name)\n\
\x20 - aws: AWS Secrets Manager, KMS-encrypted (aws_secret_name)\n\
\x20 - gcp: GCP Secret Manager (gcp_project, gcp_secret_name)\n\
\x20 - azure: Azure Key Vault (azure_vault_url, azure_secret_name)\n\
\x20 - keyring: the OS credential store: macOS Keychain, Windows Credential \
Manager, or on Linux a Secret Service provider (gnome-keyring, KWallet, \
KeePassXC) on a reachable D-Bus session\n\
Each needs its cargo feature (vault-secrets, k8s-secrets, aws-secrets, \
gcp-secrets, azure-secrets, keyring). For tests only, backend = \"plaintext\" \
with confirm_plaintext = true keeps the keys in a clear-text file."
))
}
#[allow(dead_code)] fn keyring_gate(install: impl FnOnce() -> Result<(), String>) -> Result<(), AppError> {
install().map_err(|e| {
no_secure_store(&format!(
"the OS credential store (the default secrets backend) cannot be opened: {e}"
))
})
}
#[cfg(feature = "keyring")]
pub(crate) fn ensure_keyring() -> Result<(), AppError> {
static REGISTERED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
if REGISTERED.get().is_some() {
return Ok(());
}
keyring_gate(|| vta_sdk::keyring_init::install_default_store().map_err(|e| e.to_string()))?;
let _ = REGISTERED.set(());
Ok(())
}
pub fn plaintext_path(config_path: &Path) -> PathBuf {
let dir = config_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let stem = config_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("config");
dir.join(format!("{stem}.secrets.plaintext"))
}
pub fn create_secret_store(
secrets: &SecretsConfig,
config_path: &Path,
) -> Result<Box<dyn SecretStore>, AppError> {
let vti = to_vti_config(secrets)?;
let inner: Box<dyn SeedStore> = match resolve_backend(secrets) {
SecretBackend::Keyring => keyring_store(&vti)?,
SecretBackend::Plaintext => {
let path = plaintext_path(config_path);
tracing::warn!(
path = %path.display(),
"secrets are stored in a PLAINTEXT file — for tests only, never production"
);
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config.secrets.plaintext");
Box::new(PlaintextSeedStore::with_filename(dir, name))
}
_ => vti_secrets::create_seed_store(&vti, Path::new(".")).map_err(from_vti)?,
};
Ok(Box::new(EnvelopeStore { inner }))
}
#[cfg(feature = "keyring")]
fn keyring_store(vti: &vti_secrets::SecretsConfig) -> Result<Box<dyn SeedStore>, AppError> {
ensure_keyring()?;
Ok(Box::new(vti_secrets::seed_store::KeyringSeedStore::new(
vti.keyring_service.clone(),
KEYRING_USER,
)))
}
#[cfg(not(feature = "keyring"))]
fn keyring_store(_vti: &vti_secrets::SecretsConfig) -> Result<Box<dyn SeedStore>, AppError> {
let _ = KEYRING_USER;
Err(no_secure_store(
"no backend is configured, and the default (the OS keyring) is not compiled \
into this binary",
))
}
#[cfg(test)]
mod tests;