use std::path::PathBuf;
use crate::server::config::PlaintextSecrets;
use crate::server::error::AppError;
use tracing::warn;
use super::{BoxFuture, SecretStore, ServerSecrets};
pub struct PlaintextSecretStore {
secrets: Option<ServerSecrets>,
config_path: PathBuf,
}
impl PlaintextSecretStore {
pub fn new(plaintext: Option<&PlaintextSecrets>, config_path: PathBuf) -> Self {
warn!(
"plaintext secret store is insecure — use keyring, aws-secrets, or gcp-secrets in production"
);
Self {
secrets: plaintext.map(|p| ServerSecrets {
signing_key: p.signing_key.clone(),
key_agreement_key: p.key_agreement_key.clone(),
jwt_signing_key: p.jwt_signing_key.clone(),
vta_credential: p.vta_credential.clone(),
retired: p.retired.clone(),
}),
config_path,
}
}
}
impl SecretStore for PlaintextSecretStore {
fn get(&self) -> BoxFuture<'_, Result<Option<ServerSecrets>, AppError>> {
let snapshot = self.secrets.clone();
let config_path = self.config_path.clone();
Box::pin(async move {
match read_plaintext_secrets(&config_path).await {
Ok(Some(from_file)) => Ok(Some(from_file)),
Ok(None) => Ok(snapshot),
Err(e) => {
warn!(
"failed to re-read plaintext secrets from config ({e}) — using the snapshot"
);
Ok(snapshot)
}
}
})
}
fn set(&self, secrets: &ServerSecrets) -> BoxFuture<'_, Result<(), AppError>> {
let secrets = secrets.clone();
let config_path = self.config_path.clone();
Box::pin(async move {
let contents = tokio::fs::read_to_string(&config_path).await.map_err(|e| {
AppError::Config(format!(
"failed to read config file {}: {e}",
config_path.display()
))
})?;
let mut doc: toml::Value = toml::from_str(&contents).map_err(|e| {
AppError::Config(format!(
"failed to parse config file {}: {e}",
config_path.display()
))
})?;
let plaintext = PlaintextSecrets {
signing_key: secrets.signing_key,
key_agreement_key: secrets.key_agreement_key,
jwt_signing_key: secrets.jwt_signing_key,
vta_credential: secrets.vta_credential,
retired: secrets.retired,
};
let plaintext_value = toml::Value::try_from(&plaintext).map_err(|e| {
AppError::Config(format!("failed to serialize plaintext secrets: {e}"))
})?;
let root = doc
.as_table_mut()
.ok_or_else(|| AppError::Config("config root is not a table".into()))?;
let secrets_table = root
.entry("secrets")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
.as_table_mut()
.ok_or_else(|| AppError::Config("[secrets] is not a table".into()))?;
secrets_table.insert("plaintext".to_string(), plaintext_value);
let output = toml::to_string_pretty(&doc)
.map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
tokio::fs::write(&config_path, output).await.map_err(|e| {
AppError::Config(format!(
"failed to write config file {}: {e}",
config_path.display()
))
})?;
Ok(())
})
}
fn get_bootstrap_seed(&self) -> super::BoxFuture<'_, Result<Option<[u8; 32]>, AppError>> {
let config_path = self.config_path.clone();
Box::pin(async move { read_plaintext_seed_field(&config_path).await })
}
fn set_bootstrap_seed(&self, seed: &[u8; 32]) -> super::BoxFuture<'_, Result<(), AppError>> {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
let b64 = B64.encode(seed);
let config_path = self.config_path.clone();
Box::pin(async move { write_plaintext_seed_field(&config_path, Some(&b64)).await })
}
fn clear_bootstrap_seed(&self) -> super::BoxFuture<'_, Result<(), AppError>> {
let config_path = self.config_path.clone();
Box::pin(async move { write_plaintext_seed_field(&config_path, None).await })
}
}
async fn read_plaintext_secrets(
config_path: &std::path::Path,
) -> Result<Option<ServerSecrets>, AppError> {
let contents = match tokio::fs::read_to_string(config_path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(AppError::Config(format!(
"failed to read config file {}: {e}",
config_path.display()
)));
}
};
let doc: toml::Value = toml::from_str(&contents).map_err(|e| {
AppError::Config(format!(
"failed to parse config file {}: {e}",
config_path.display()
))
})?;
let Some(value) = doc.get("secrets").and_then(|s| s.get("plaintext")) else {
return Ok(None);
};
let plaintext: PlaintextSecrets = value
.clone()
.try_into()
.map_err(|e| AppError::Config(format!("failed to parse [secrets.plaintext]: {e}")))?;
Ok(Some(ServerSecrets {
signing_key: plaintext.signing_key,
key_agreement_key: plaintext.key_agreement_key,
jwt_signing_key: plaintext.jwt_signing_key,
vta_credential: plaintext.vta_credential,
retired: plaintext.retired,
}))
}
async fn read_plaintext_seed_field(
config_path: &std::path::Path,
) -> Result<Option<[u8; 32]>, AppError> {
let contents = match tokio::fs::read_to_string(config_path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(AppError::Config(format!(
"failed to read config file {}: {e}",
config_path.display()
)));
}
};
let doc: toml::Value = toml::from_str(&contents).map_err(|e| {
AppError::Config(format!(
"failed to parse config file {}: {e}",
config_path.display()
))
})?;
let Some(b64) = doc
.get("secrets")
.and_then(|s| s.get("plaintext_bootstrap_seed"))
.and_then(|v| v.as_str())
else {
return Ok(None);
};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
let bytes = B64.decode(b64.as_bytes()).map_err(|e| {
AppError::SecretStore(format!(
"failed to base64-decode plaintext bootstrap seed: {e}"
))
})?;
let seed: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
AppError::SecretStore(format!(
"plaintext bootstrap seed has {} bytes, expected 32",
bytes.len()
))
})?;
Ok(Some(seed))
}
async fn write_plaintext_seed_field(
config_path: &std::path::Path,
value: Option<&str>,
) -> Result<(), AppError> {
let contents = match tokio::fs::read_to_string(config_path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(AppError::Config(format!(
"failed to read config file {}: {e}",
config_path.display()
)));
}
};
let mut doc: toml::Value = if contents.is_empty() {
toml::Value::Table(toml::map::Map::new())
} else {
toml::from_str(&contents).map_err(|e| {
AppError::Config(format!(
"failed to parse config file {}: {e}",
config_path.display()
))
})?
};
let root = doc
.as_table_mut()
.ok_or_else(|| AppError::Config("config root is not a table".into()))?;
let secrets_table = root
.entry("secrets")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
.as_table_mut()
.ok_or_else(|| AppError::Config("[secrets] is not a table".into()))?;
match value {
Some(b64) => {
secrets_table.insert(
"plaintext_bootstrap_seed".to_string(),
toml::Value::String(b64.to_string()),
);
}
None => {
secrets_table.remove("plaintext_bootstrap_seed");
}
}
let output = toml::to_string_pretty(&doc)
.map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
if let Some(parent) = config_path.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent).await.map_err(|e| {
AppError::Config(format!(
"failed to create config dir {}: {e}",
parent.display()
))
})?;
}
tokio::fs::write(config_path, output).await.map_err(|e| {
AppError::Config(format!(
"failed to write config file {}: {e}",
config_path.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_secrets() -> ServerSecrets {
ServerSecrets {
signing_key: "z6Mktest_signing".into(),
key_agreement_key: "z6LStest_agreement".into(),
jwt_signing_key: "z6Mktest_jwt".into(),
vta_credential: None,
retired: Vec::new(),
}
}
fn sample_plaintext() -> PlaintextSecrets {
PlaintextSecrets {
signing_key: "z6Mktest_signing".into(),
key_agreement_key: "z6LStest_agreement".into(),
jwt_signing_key: "z6Mktest_jwt".into(),
vta_credential: None,
retired: Vec::new(),
}
}
#[tokio::test]
async fn get_returns_none_when_no_plaintext_configured() {
let store = PlaintextSecretStore::new(None, PathBuf::from("nonexistent.toml"));
let result = store.get().await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn a_wholesale_key_import_must_carry_retired_material_forward() {
use crate::server::secret_store::RetiredKeys;
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
tokio::fs::write(&config_path, "[server]\nport = 8080\n")
.await
.expect("seed config");
let store = PlaintextSecretStore::new(None, config_path.clone());
let mut secrets = sample_secrets();
secrets.retired = vec![RetiredKeys {
ka_kid: "did:webvh:example:alpha#key-1".into(),
key_agreement_key: "z6LSold_private".into(),
signing_kid: "did:webvh:example:alpha#key-0".into(),
signing_key: "z6Mkold_private".into(),
}];
store.set(&secrets).await.expect("write rotated secrets");
let existing = store.get().await.unwrap().expect("secrets present");
let imported = ServerSecrets {
signing_key: "z6MkImported".into(),
key_agreement_key: "z6LSImported".into(),
jwt_signing_key: "z6MkImportedJwt".into(),
vta_credential: None,
retired: existing.retired, };
store.set(&imported).await.expect("write imported secrets");
let after = store.get().await.unwrap().expect("secrets present");
assert_eq!(
after.key_agreement_key, "z6LSImported",
"import took effect"
);
assert_eq!(
after.retired.len(),
1,
"importing new keys must not destroy a live generation's key material"
);
assert_eq!(after.retired[0].key_agreement_key, "z6LSold_private");
}
#[tokio::test]
async fn set_preserves_retired_key_material() {
use crate::server::secret_store::RetiredKeys;
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
tokio::fs::write(&config_path, "[server]\nport = 8080\n")
.await
.expect("seed config");
let store = PlaintextSecretStore::new(None, config_path.clone());
let mut secrets = sample_secrets();
secrets.retired = vec![RetiredKeys {
ka_kid: "did:webvh:example:alpha#z6LSold".into(),
key_agreement_key: "z6LSold_private".into(),
signing_kid: "did:webvh:example:alpha#z6Mkold".into(),
signing_key: "z6Mkold_private".into(),
}];
store.set(&secrets).await.expect("write secrets");
let contents = tokio::fs::read_to_string(&config_path)
.await
.expect("read config");
let parsed: toml::Value = toml::from_str(&contents).expect("parse config");
let plaintext: PlaintextSecrets = parsed["secrets"]["plaintext"]
.clone()
.try_into()
.expect("plaintext secrets present");
assert_eq!(
plaintext.retired.len(),
1,
"retired key material must survive the write that installs its replacement"
);
assert_eq!(
plaintext.retired[0].ka_kid,
"did:webvh:example:alpha#z6LSold"
);
assert_eq!(plaintext.retired[0].key_agreement_key, "z6LSold_private");
}
#[tokio::test]
async fn get_returns_secrets_when_plaintext_configured() {
let pt = sample_plaintext();
let store = PlaintextSecretStore::new(Some(&pt), PathBuf::from("unused.toml"));
let result = store.get().await.unwrap().expect("should have secrets");
assert_eq!(result.signing_key, "z6Mktest_signing");
assert_eq!(result.key_agreement_key, "z6LStest_agreement");
assert_eq!(result.jwt_signing_key, "z6Mktest_jwt");
}
#[tokio::test]
async fn set_writes_plaintext_section_to_config_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
tokio::fs::write(&config_path, "[server]\nhost = \"0.0.0.0\"\n")
.await
.unwrap();
let store = PlaintextSecretStore::new(None, config_path.clone());
store.set(&sample_secrets()).await.unwrap();
let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
let doc: toml::Value = toml::from_str(&contents).unwrap();
let plaintext = doc["secrets"]["plaintext"].as_table().unwrap();
assert_eq!(
plaintext["signing_key"].as_str().unwrap(),
"z6Mktest_signing"
);
assert_eq!(
plaintext["key_agreement_key"].as_str().unwrap(),
"z6LStest_agreement"
);
assert_eq!(
plaintext["jwt_signing_key"].as_str().unwrap(),
"z6Mktest_jwt"
);
}
#[tokio::test]
async fn set_preserves_existing_config_fields() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let initial = r#"
[server]
host = "127.0.0.1"
port = 9000
[secrets]
keyring_service = "my-service"
"#;
tokio::fs::write(&config_path, initial).await.unwrap();
let store = PlaintextSecretStore::new(None, config_path.clone());
store.set(&sample_secrets()).await.unwrap();
let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
let doc: toml::Value = toml::from_str(&contents).unwrap();
assert_eq!(doc["server"]["host"].as_str().unwrap(), "127.0.0.1");
assert_eq!(doc["server"]["port"].as_integer().unwrap(), 9000);
assert_eq!(
doc["secrets"]["keyring_service"].as_str().unwrap(),
"my-service"
);
assert!(doc["secrets"]["plaintext"].is_table());
}
#[tokio::test]
async fn set_then_reload_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let initial = r#"
[features]
didcomm = false
rest_api = true
"#;
tokio::fs::write(&config_path, initial).await.unwrap();
let store = PlaintextSecretStore::new(None, config_path.clone());
store.set(&sample_secrets()).await.unwrap();
let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
let doc: toml::Value = toml::from_str(&contents).unwrap();
let pt_value = &doc["secrets"]["plaintext"];
let reloaded: PlaintextSecrets = pt_value
.clone()
.try_into()
.expect("should deserialize PlaintextSecrets");
let store2 = PlaintextSecretStore::new(Some(&reloaded), config_path);
let result = store2.get().await.unwrap().expect("should have secrets");
assert_eq!(result.signing_key, "z6Mktest_signing");
assert_eq!(result.key_agreement_key, "z6LStest_agreement");
assert_eq!(result.jwt_signing_key, "z6Mktest_jwt");
}
#[tokio::test]
async fn set_errors_on_missing_config_file() {
let store = PlaintextSecretStore::new(None, PathBuf::from("/nonexistent/path/config.toml"));
let result = store.set(&sample_secrets()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn bootstrap_seed_set_get_clear_roundtrip_via_config_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
tokio::fs::write(&config_path, "[server]\nhost = \"0.0.0.0\"\n")
.await
.unwrap();
let phase1 = PlaintextSecretStore::new(None, config_path.clone());
let seed = [42u8; 32];
phase1.set_bootstrap_seed(&seed).await.unwrap();
let phase2 = PlaintextSecretStore::new(None, config_path.clone());
let read = phase2
.get_bootstrap_seed()
.await
.unwrap()
.expect("phase 2 must read the seed phase 1 wrote, even with a stale snapshot");
assert_eq!(read, seed);
phase2.clear_bootstrap_seed().await.unwrap();
let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
let doc: toml::Value = toml::from_str(&contents).unwrap();
assert!(doc["secrets"].get("plaintext_bootstrap_seed").is_none());
assert!(phase2.get_bootstrap_seed().await.unwrap().is_none());
}
#[tokio::test]
#[cfg(not(feature = "keyring"))]
async fn create_secret_store_bootstrap_seed_survives_wizard_serialisation() {
use crate::server::config::SecretsConfig;
use crate::server::secret_store::create_secret_store;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
tokio::fs::write(&config_path, "[server]\nhost = \"0.0.0.0\"\n")
.await
.unwrap();
let secrets = SecretsConfig::default();
let phase1 = create_secret_store(&secrets, &config_path).unwrap();
let seed = [7u8; 32];
phase1.set_bootstrap_seed(&seed).await.unwrap();
let state_toml = toml::to_string_pretty(&secrets).unwrap();
let snapshot: SecretsConfig = toml::from_str(&state_toml).unwrap();
assert!(
snapshot.plaintext_bootstrap_seed.is_none(),
"snapshot is stale by design — fix must not depend on it being populated"
);
let phase2 = create_secret_store(&snapshot, &config_path).unwrap();
let read = phase2
.get_bootstrap_seed()
.await
.unwrap()
.expect("phase 2 must read the seed regardless of SecretsConfig staleness");
assert_eq!(read, seed);
}
#[tokio::test]
async fn bootstrap_seed_get_errors_on_malformed_seed() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
tokio::fs::write(
&config_path,
"[secrets]\nplaintext_bootstrap_seed = \"AAAA\"\n",
)
.await
.unwrap();
let store = PlaintextSecretStore::new(None, config_path);
let err = store.get_bootstrap_seed().await.unwrap_err();
assert!(
matches!(err, AppError::SecretStore(_)),
"expected SecretStore, got {err:?}"
);
}
#[tokio::test]
async fn set_persists_vta_credential_and_round_trips_via_get() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
tokio::fs::write(&config_path, "[server]\nhost = \"0.0.0.0\"\n")
.await
.unwrap();
let store = PlaintextSecretStore::new(None, config_path.clone());
let mut s = sample_secrets();
s.vta_credential = Some("opaque-vta-credential-blob".into());
store.set(&s).await.unwrap();
let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
let doc: toml::Value = toml::from_str(&contents).unwrap();
let pt_value = &doc["secrets"]["plaintext"];
let reloaded: PlaintextSecrets = pt_value.clone().try_into().unwrap();
assert_eq!(
reloaded.vta_credential.as_deref(),
Some("opaque-vta-credential-blob")
);
let store2 = PlaintextSecretStore::new(Some(&reloaded), config_path);
let read = store2.get().await.unwrap().expect("secrets present");
assert_eq!(
read.vta_credential.as_deref(),
Some("opaque-vta-credential-blob")
);
}
#[tokio::test]
async fn bootstrap_seed_get_returns_none_when_unset() {
let store = PlaintextSecretStore::new(None, PathBuf::from("nonexistent.toml"));
assert!(store.get_bootstrap_seed().await.unwrap().is_none());
}
}