use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use bip39::Mnemonic;
use bitcoin::bip32::Xpriv;
use bitcoin::hashes::{sha256, Hash};
use bitcoin::secp256k1::Secp256k1;
use bitcoin::Network;
use cdk_signatory::signatory::Signatory;
use thiserror::Error;
use crate::config::{Database, DatabaseEngine, Settings};
use crate::config_store::{ConfigEnvelope, ConfigRepository, ConfigStoreError, DocumentState};
use crate::secret::{SecretRef, SecretRefError, SecretResolveError};
const SIGNING_IDENTITY_DOMAIN: &[u8] = b"cdk-mintd/signing-identity/v1\0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SigningIdentity {
pub(crate) pubkey: cdk::nuts::PublicKey,
pub(crate) fingerprint: String,
}
#[derive(Clone)]
pub struct ResolvedConfiguration {
pub document: String,
pub settings: Settings,
}
impl fmt::Debug for ResolvedConfiguration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ResolvedConfiguration")
.field("document", &self.document)
.field("settings", &"[resolved configuration redacted]")
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub(crate) struct StartupConfiguration {
pub(crate) resolved: ResolvedConfiguration,
pub(crate) state: DocumentState,
pub(crate) revision: u64,
pub(crate) signing_identity: SigningIdentity,
pub(crate) remote_signatory: Option<Arc<cdk_signatory::SignatoryRpcClient>>,
}
impl fmt::Debug for StartupConfiguration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StartupConfiguration")
.field("resolved", &self.resolved)
.field("state", &self.state)
.field("revision", &self.revision)
.field("signing_identity", &self.signing_identity)
.field("remote_signatory", &self.remote_signatory.is_some())
.finish()
}
}
struct SigningIdentityResolution {
identity: SigningIdentity,
remote_signatory: Option<Arc<cdk_signatory::SignatoryRpcClient>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApplyOutcome {
pub restart_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RollbackOutcome {
pub restart_required: bool,
}
#[derive(Debug, Error)]
pub enum ConfigurationServiceError {
#[error("invalid mintd configuration document: {0}")]
Parse(#[from] config::ConfigError),
#[error("{field} must use an `env:VARIABLE` or `file:/absolute/path` secret reference")]
LiteralSecret {
field: &'static str,
},
#[error("could not resolve {field} from environment variable {name}")]
EnvironmentSecret {
field: &'static str,
name: String,
},
#[error("could not resolve {field} from secret file {}: {source}", path.display())]
FileSecret {
field: &'static str,
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("secret reference for {field} resolved to an empty value")]
EmptySecret {
field: &'static str,
},
#[error("invalid mintd configuration: {0}")]
Validation(String),
#[error("primary database settings do not match the bootstrap database settings")]
PrimaryDatabaseChange,
#[error("could not determine configured mint signing identity: {0}")]
SigningIdentity(String),
#[error(
"configured signing identity does not match this mint database; signer migration is not supported by config apply"
)]
SigningIdentityChange,
#[error(transparent)]
Store(#[from] ConfigStoreError),
}
#[derive(Debug, Clone)]
pub(crate) struct ConfigurationService {
repository: ConfigRepository,
primary_database: Database,
}
impl ConfigurationService {
pub(crate) fn new(repository: ConfigRepository, primary_database: Database) -> Self {
Self {
repository,
primary_database,
}
}
pub fn validate_document(
document: &str,
) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
let mut settings = Settings::try_from_toml(document)?;
validate_secret_references(&settings)?;
prune_inactive_configuration(&mut settings);
resolve_secrets(&mut settings)?;
crate::validate_settings(&settings)
.map_err(|error| ConfigurationServiceError::Validation(error.to_string()))?;
Ok(ResolvedConfiguration {
document: document.to_owned(),
settings,
})
}
pub(crate) async fn validate_import(
document: &str,
) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
Ok(Self::validated_import(document).await?.0)
}
pub(crate) async fn initialize(
&self,
document: &str,
database_pubkey: Option<cdk::nuts::PublicKey>,
) -> Result<(), ConfigurationServiceError> {
let (resolved, signing_identity) = Self::validated_import(document).await?;
self.require_primary_database(&resolved.settings.database)?;
if database_pubkey.is_some_and(|pubkey| pubkey != signing_identity.pubkey) {
return Err(ConfigurationServiceError::SigningIdentityChange);
}
self.repository
.initialize(ConfigEnvelope::new(
resolved.document,
signing_identity.fingerprint,
))
.await?;
Ok(())
}
pub(crate) async fn apply(
&self,
document: &str,
validate_only: bool,
) -> Result<ApplyOutcome, ConfigurationServiceError> {
let (resolved, signing_identity) = Self::validated_import(document).await?;
self.require_primary_database(&resolved.settings.database)?;
let current = self.repository.active().await?;
if current.signing_identity != signing_identity.fingerprint {
return Err(ConfigurationServiceError::SigningIdentityChange);
}
if !validate_only {
self.repository
.replace(resolved.document, &signing_identity.fingerprint)
.await?;
}
Ok(ApplyOutcome {
restart_required: !validate_only,
})
}
pub(crate) async fn startup(&self) -> Result<StartupConfiguration, ConfigurationServiceError> {
let envelope = self.repository.active().await?;
let resolved = Self::validate_document(&envelope.toml)?;
self.require_primary_database(&resolved.settings.database)?;
let signing_resolution = resolve_signing_identity_async(&resolved.settings).await?;
validate_authored_mint_pubkey(&resolved.settings, &signing_resolution.identity)?;
if envelope.signing_identity != signing_resolution.identity.fingerprint {
return Err(ConfigurationServiceError::SigningIdentityChange);
}
Ok(StartupConfiguration {
resolved,
state: envelope.state(),
revision: envelope.revision,
signing_identity: signing_resolution.identity,
remote_signatory: signing_resolution.remote_signatory,
})
}
pub(crate) async fn document(&self) -> Result<String, ConfigurationServiceError> {
Ok(self.repository.active().await?.toml)
}
#[cfg(any(feature = "management-rpc", test))]
pub(crate) async fn has_pending_configuration(
&self,
) -> Result<bool, ConfigurationServiceError> {
Ok(matches!(
self.repository.active().await?.state(),
DocumentState::Pending
))
}
pub(crate) async fn mark_applied(
&self,
expected_revision: u64,
) -> Result<bool, ConfigurationServiceError> {
Ok(self.repository.mark_applied(expected_revision).await?)
}
pub(crate) async fn rollback(&self) -> Result<RollbackOutcome, ConfigurationServiceError> {
Ok(RollbackOutcome {
restart_required: self.repository.rollback().await?,
})
}
fn require_primary_database(
&self,
configured: &Database,
) -> Result<(), ConfigurationServiceError> {
if !same_primary_database(configured, &self.primary_database) {
return Err(ConfigurationServiceError::PrimaryDatabaseChange);
}
Ok(())
}
async fn validated_import(
document: &str,
) -> Result<(ResolvedConfiguration, SigningIdentity), ConfigurationServiceError> {
let resolved = Self::validate_document(document)?;
let signing_identity = discover_signing_identity_async(&resolved.settings).await?;
validate_authored_mint_pubkey(&resolved.settings, &signing_identity)?;
Ok((resolved, signing_identity))
}
}
pub(crate) fn discover_signing_identity(
settings: &Settings,
) -> Result<SigningIdentity, ConfigurationServiceError> {
let pubkey = if settings.enabled_signatory().is_some() {
return Err(ConfigurationServiceError::SigningIdentity(
"remote signatory identity requires asynchronous validation".to_owned(),
));
} else if let Some(seed) = settings
.info
.seed
.as_deref()
.filter(|seed| !seed.is_empty())
{
root_pubkey(seed.as_bytes())?
} else if let Some(mnemonic) = settings.info.mnemonic.as_deref() {
let mnemonic = Mnemonic::from_str(mnemonic)
.map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?;
root_pubkey(&mnemonic.to_seed_normalized(""))?
} else {
return Err(ConfigurationServiceError::SigningIdentity(
"no local signing source is configured".to_owned(),
));
};
Ok(signing_identity_from_pubkey(pubkey))
}
pub(crate) async fn discover_signing_identity_async(
settings: &Settings,
) -> Result<SigningIdentity, ConfigurationServiceError> {
Ok(resolve_signing_identity_async(settings).await?.identity)
}
async fn resolve_signing_identity_async(
settings: &Settings,
) -> Result<SigningIdentityResolution, ConfigurationServiceError> {
if let Some(signatory) = settings.enabled_signatory() {
let client = Arc::new(
cdk_signatory::SignatoryRpcClient::new(
&signatory.address,
signatory.port,
signatory.tls_dir.clone(),
)
.await
.map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?,
);
let pubkey = client
.keysets()
.await
.map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?
.pubkey;
Ok(SigningIdentityResolution {
identity: signing_identity_from_pubkey(pubkey),
remote_signatory: Some(client),
})
} else {
Ok(SigningIdentityResolution {
identity: discover_signing_identity(settings)?,
remote_signatory: None,
})
}
}
fn root_pubkey(seed: &[u8]) -> Result<cdk::nuts::PublicKey, ConfigurationServiceError> {
let secp = Secp256k1::new();
let xpriv = Xpriv::new_master(Network::Bitcoin, seed)
.map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?;
Ok(xpriv.to_keypair(&secp).public_key().into())
}
fn signing_identity_from_pubkey(pubkey: cdk::nuts::PublicKey) -> SigningIdentity {
let mut input = SIGNING_IDENTITY_DOMAIN.to_vec();
input.extend_from_slice(&pubkey.to_bytes());
SigningIdentity {
pubkey,
fingerprint: sha256::Hash::hash(&input).to_string(),
}
}
fn validate_authored_mint_pubkey(
settings: &Settings,
signing_identity: &SigningIdentity,
) -> Result<(), ConfigurationServiceError> {
if settings
.mint_info
.pubkey
.is_some_and(|pubkey| pubkey != signing_identity.pubkey)
{
return Err(ConfigurationServiceError::SigningIdentityChange);
}
Ok(())
}
fn same_primary_database(configured: &Database, bootstrap: &Database) -> bool {
if configured.engine != bootstrap.engine {
return false;
}
if configured.engine != DatabaseEngine::Postgres {
return true;
}
match (&configured.postgres, &bootstrap.postgres) {
(Some(configured), Some(bootstrap)) => {
configured.url == bootstrap.url
&& configured.tls_mode == bootstrap.tls_mode
&& configured.max_connections == bootstrap.max_connections
&& configured.connection_timeout_seconds == bootstrap.connection_timeout_seconds
}
_ => false,
}
}
pub(crate) fn prune_inactive_configuration(settings: &mut Settings) {
if settings.database.engine != DatabaseEngine::Postgres {
settings.database.postgres = None;
}
if settings
.auth
.as_ref()
.is_some_and(|auth| !auth.auth_enabled)
{
settings.auth = None;
}
if settings.auth.is_none() || settings.database.engine != DatabaseEngine::Postgres {
settings.auth_database = None;
}
if settings
.signatory
.as_ref()
.is_some_and(|signatory| !signatory.enabled)
{
settings.signatory = None;
}
#[cfg(feature = "ldk-node")]
if !settings
.payment_backend
.iter()
.any(|backend| backend.backend == crate::config::PaymentBackendType::LdkNode)
{
settings.ldk_node = None;
}
#[cfg(feature = "bdk")]
if !settings
.onchain
.as_ref()
.is_some_and(|onchain| onchain.onchain_backend == crate::config::OnchainBackend::Bdk)
{
settings.bdk = None;
}
}
fn validate_secret_references(settings: &Settings) -> Result<(), ConfigurationServiceError> {
validate_optional_secret_reference(settings.info.seed.as_deref(), "info.seed")?;
validate_optional_secret_reference(settings.info.mnemonic.as_deref(), "info.mnemonic")?;
if let Some(postgres) = settings.database.postgres.as_ref() {
validate_secret_reference(&postgres.url, "database.postgres.url")?;
}
if let Some(postgres) = settings
.auth_database
.as_ref()
.and_then(|database| database.postgres.as_ref())
{
validate_secret_reference(&postgres.url, "auth_database.postgres.url")?;
}
#[cfg(feature = "bdk")]
if let Some(bdk) = settings.bdk.as_ref() {
validate_optional_secret_reference(
bdk.bitcoind_rpc_password.as_deref(),
"bdk.bitcoind_rpc_password",
)?;
validate_optional_secret_reference(bdk.mnemonic.as_deref(), "bdk.mnemonic")?;
}
#[cfg(feature = "ldk-node")]
if let Some(ldk_node) = settings.ldk_node.as_ref() {
validate_optional_secret_reference(
ldk_node.bitcoind_rpc_password.as_deref(),
"ldk_node.bitcoind_rpc_password",
)?;
validate_optional_secret_reference(
ldk_node.ldk_node_mnemonic.as_deref(),
"ldk_node.ldk_node_mnemonic",
)?;
}
#[cfg(feature = "redis")]
if let cdk_axum::cache::Backend::Redis(redis) = &settings.info.http_cache.backend {
validate_secret_reference(
&redis.connection_string,
"info.http_cache.connection_string",
)?;
if let Some(cluster_nodes) = redis.cluster_nodes.as_ref() {
for node in cluster_nodes {
validate_secret_reference(node, "info.http_cache.cluster_nodes")?;
}
}
}
Ok(())
}
fn validate_optional_secret_reference(
value: Option<&str>,
field: &'static str,
) -> Result<(), ConfigurationServiceError> {
if let Some(value) = value {
validate_secret_reference(value, field)?;
}
Ok(())
}
fn validate_secret_reference(
value: &str,
field: &'static str,
) -> Result<(), ConfigurationServiceError> {
if value.is_empty() {
return Ok(());
}
SecretRef::parse(value).map_err(|error| match error {
SecretRefError::EmptyEnvironmentName => ConfigurationServiceError::EmptySecret { field },
SecretRefError::Literal
| SecretRefError::EmptyFilePath
| SecretRefError::RelativeFilePath { .. } => {
ConfigurationServiceError::LiteralSecret { field }
}
})?;
Ok(())
}
fn resolve_secrets(settings: &mut Settings) -> Result<(), ConfigurationServiceError> {
if settings.enabled_signatory().is_none() {
resolve_optional_secret(&mut settings.info.seed, "info.seed")?;
resolve_optional_trimmed_secret(&mut settings.info.mnemonic, "info.mnemonic")?;
}
if let Some(postgres) = settings.database.postgres.as_mut() {
resolve_secret(&mut postgres.url, "database.postgres.url")?;
}
if let Some(postgres) = settings
.auth_database
.as_mut()
.and_then(|database| database.postgres.as_mut())
{
resolve_secret(&mut postgres.url, "auth_database.postgres.url")?;
}
#[cfg(feature = "bdk")]
if let Some(bdk) = settings.bdk.as_mut() {
resolve_optional_secret(&mut bdk.bitcoind_rpc_password, "bdk.bitcoind_rpc_password")?;
resolve_optional_trimmed_secret(&mut bdk.mnemonic, "bdk.mnemonic")?;
}
#[cfg(feature = "ldk-node")]
if let Some(ldk_node) = settings.ldk_node.as_mut() {
resolve_optional_secret(
&mut ldk_node.bitcoind_rpc_password,
"ldk_node.bitcoind_rpc_password",
)?;
resolve_optional_trimmed_secret(
&mut ldk_node.ldk_node_mnemonic,
"ldk_node.ldk_node_mnemonic",
)?;
}
#[cfg(feature = "redis")]
if let cdk_axum::cache::Backend::Redis(redis) = &mut settings.info.http_cache.backend {
resolve_secret(
&mut redis.connection_string,
"info.http_cache.connection_string",
)?;
if let Some(cluster_nodes) = redis.cluster_nodes.as_mut() {
for node in cluster_nodes {
resolve_secret(node, "info.http_cache.cluster_nodes")?;
}
}
}
Ok(())
}
fn resolve_optional_secret(
value: &mut Option<String>,
field: &'static str,
) -> Result<(), ConfigurationServiceError> {
if let Some(value) = value.as_mut() {
resolve_secret(value, field)?;
}
Ok(())
}
fn resolve_optional_trimmed_secret(
value: &mut Option<String>,
field: &'static str,
) -> Result<(), ConfigurationServiceError> {
if let Some(value) = value.as_mut() {
resolve_trimmed_secret(value, field)?;
}
Ok(())
}
fn resolve_secret(
value: &mut String,
field: &'static str,
) -> Result<(), ConfigurationServiceError> {
resolve_secret_with(value, field, |resolved| resolved)
}
fn resolve_trimmed_secret(
value: &mut String,
field: &'static str,
) -> Result<(), ConfigurationServiceError> {
resolve_secret_with(value, field, |resolved| resolved.trim().to_owned())
}
fn resolve_secret_with(
value: &mut String,
field: &'static str,
normalize: impl FnOnce(String) -> String,
) -> Result<(), ConfigurationServiceError> {
validate_secret_reference(value, field)?;
if value.is_empty() {
return Ok(());
}
let reference = SecretRef::parse(value).map_err(|error| match error {
SecretRefError::EmptyEnvironmentName => ConfigurationServiceError::EmptySecret { field },
SecretRefError::Literal
| SecretRefError::EmptyFilePath
| SecretRefError::RelativeFilePath { .. } => {
ConfigurationServiceError::LiteralSecret { field }
}
})?;
let resolved = reference.resolve().map_err(|error| match error {
SecretResolveError::Environment { name } => {
ConfigurationServiceError::EnvironmentSecret { field, name }
}
SecretResolveError::File { path, source } => ConfigurationServiceError::FileSecret {
field,
path,
source,
},
})?;
let resolved = normalize(resolved);
if resolved.is_empty() {
return Err(ConfigurationServiceError::EmptySecret { field });
}
*value = resolved;
Ok(())
}
#[cfg(test)]
mod tests {
#[cfg(feature = "sqlite")]
use std::sync::Arc;
#[cfg(feature = "sqlite")]
use cdk_sqlite::mint::memory;
use super::*;
const TEST_MNEMONIC_ONE: &str =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const TEST_MNEMONIC_TWO: &str =
"legal winner thank year wave sausage worth useful legal winner thank yellow";
#[cfg(feature = "fakewallet")]
fn document(secret_reference: &str, name: &str) -> String {
format!(
r#"
[info]
mnemonic = "{secret_reference}"
[mint_info]
name = "{name}"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
"#
)
}
#[cfg(feature = "fakewallet")]
fn remote_signatory_document(info_fields: &str, extra_sections: &str) -> String {
format!(
r#"
[info]
{info_fields}
[signatory]
enabled = true
allow_insecure = true
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
{extra_sections}
"#
)
}
#[cfg(feature = "sqlite")]
async fn service() -> ConfigurationService {
let database = Arc::new(memory::empty().await.expect("in-memory database"));
ConfigurationService::new(ConfigRepository::new(database), Database::default())
}
#[test]
fn literal_signing_secret_is_rejected() {
let error = ConfigurationService::validate_document(
r#"
[info]
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
"#,
)
.expect_err("literal mnemonic should fail");
assert!(matches!(
error,
ConfigurationServiceError::LiteralSecret {
field: "info.mnemonic"
}
));
}
#[cfg(feature = "fakewallet")]
#[test]
fn remote_signatory_rejects_local_signing_material_without_resolving_it() {
let missing_secret =
crate::test_utils::unique_temp_path("remote_signatory_unused_local_secret");
let document = format!(
r#"
[info]
mnemonic = "file:{}"
[signatory]
enabled = true
allow_insecure = true
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
"#,
missing_secret.display()
);
let error = ConfigurationService::validate_document(&document)
.expect_err("remote and local signing sources should conflict");
assert!(
matches!(
&error,
ConfigurationServiceError::Validation(message)
if message.contains("Remote signatory configuration cannot include")
),
"unexpected error: {error}"
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn remote_signatory_configuration_does_not_require_local_signing_material() {
let resolved = ConfigurationService::validate_document(
r#"
[signatory]
enabled = true
allow_insecure = true
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
"#,
)
.expect("remote signatory should be a complete signing source");
assert!(resolved.settings.info.seed.is_none());
assert!(resolved.settings.info.mnemonic.is_none());
}
#[cfg(feature = "fakewallet")]
#[test]
fn literal_secrets_in_inactive_or_skipped_sections_are_rejected() {
let assert_rejected = |document: String, expected_field| {
let error = ConfigurationService::validate_document(&document)
.expect_err("literal secret should be rejected before pruning");
assert!(
matches!(
error,
ConfigurationServiceError::LiteralSecret { field }
if field == expected_field
),
"unexpected error for {expected_field}: {error}"
);
};
assert_rejected(
remote_signatory_document(r#"seed = "plaintext-secret""#, ""),
"info.seed",
);
assert_rejected(
remote_signatory_document(
"",
r#"
[database.postgres]
url = "postgresql://operator:plaintext-secret@localhost/cdk"
"#,
),
"database.postgres.url",
);
assert_rejected(
remote_signatory_document(
"",
r#"
[auth_database.postgres]
url = "postgresql://operator:plaintext-secret@localhost/cdk"
"#,
),
"auth_database.postgres.url",
);
#[cfg(feature = "bdk")]
assert_rejected(
remote_signatory_document(
"",
r#"
[bdk]
mnemonic = "plaintext-secret"
"#,
),
"bdk.mnemonic",
);
#[cfg(feature = "ldk-node")]
assert_rejected(
remote_signatory_document(
"",
r#"
[ldk_node]
ldk_node_mnemonic = "plaintext-secret"
"#,
),
"ldk_node.ldk_node_mnemonic",
);
#[cfg(feature = "redis")]
assert_rejected(
remote_signatory_document(
"",
r#"
[info.http_cache]
backend = "redis"
connection_string = "redis://operator:plaintext-secret@localhost"
"#,
),
"info.http_cache.connection_string",
);
#[cfg(feature = "redis")]
assert_rejected(
remote_signatory_document(
"",
r#"
[info.http_cache]
backend = "redis"
connection_string = ""
cluster_nodes = ["redis://operator:plaintext-secret@localhost"]
"#,
),
"info.http_cache.cluster_nodes",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn inactive_secret_references_are_validated_but_not_resolved() {
let _env_lock = crate::test_utils::env_lock();
const MISSING: &str = "CDK_MINTD_TEST_MISSING_INACTIVE_POSTGRES_SECRET";
std::env::remove_var(MISSING);
let document = remote_signatory_document(
"",
&format!(
r#"
[database.postgres]
url = "env:{MISSING}"
"#
),
);
ConfigurationService::validate_document(&document)
.expect("inactive valid reference should not be resolved");
}
#[cfg(all(feature = "sqlite", feature = "fakewallet"))]
#[tokio::test]
async fn rejected_inactive_literal_secret_is_not_persisted() {
let service = service().await;
let document = remote_signatory_document(
"",
r#"
[database.postgres]
url = "postgresql://operator:plaintext-secret@localhost/cdk"
"#,
);
assert!(matches!(
service.initialize(&document, None).await,
Err(ConfigurationServiceError::LiteralSecret {
field: "database.postgres.url"
})
));
assert!(matches!(
service.document().await,
Err(ConfigurationServiceError::Store(
ConfigStoreError::NotInitialized
))
));
}
#[cfg(feature = "fakewallet")]
#[test]
fn missing_empty_and_relative_secret_references_are_rejected() {
let _env_lock = crate::test_utils::env_lock();
const MISSING: &str = "CDK_MINTD_TEST_MISSING_CONFIG_SECRET";
const EMPTY: &str = "CDK_MINTD_TEST_EMPTY_CONFIG_SECRET";
std::env::remove_var(MISSING);
std::env::set_var(EMPTY, " ");
assert!(matches!(
ConfigurationService::validate_document(&document(
&format!("env:{MISSING}"),
"missing"
)),
Err(ConfigurationServiceError::EnvironmentSecret { .. })
));
assert!(matches!(
ConfigurationService::validate_document(&document(&format!("env:{EMPTY}"), "empty")),
Err(ConfigurationServiceError::EmptySecret { .. })
));
assert!(matches!(
ConfigurationService::validate_document(&document("file:relative/secret", "relative")),
Err(ConfigurationServiceError::LiteralSecret { .. })
));
std::env::remove_var(EMPTY);
}
#[cfg(all(feature = "sqlite", feature = "fakewallet"))]
#[tokio::test]
async fn initialize_apply_and_validate_only_use_one_record() {
let secret_path = crate::test_utils::unique_temp_path("atomic_config_secret");
std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
let secret_reference = format!("file:{}", secret_path.display());
let service = service().await;
let first = document(&secret_reference, "first");
let second = document(&secret_reference, "second");
service
.initialize(&first, None)
.await
.expect("initialize configuration");
assert!(matches!(
service.initialize(&first, None).await,
Err(ConfigurationServiceError::Store(
ConfigStoreError::AlreadyInitialized
))
));
let outcome = service
.apply(&second, true)
.await
.expect("validate replacement");
assert!(!outcome.restart_required);
assert_eq!(service.document().await.expect("stored document"), first);
let running_snapshot = service.startup().await.expect("running snapshot");
assert!(service
.mark_applied(running_snapshot.revision)
.await
.expect("mark first document applied"));
let outcome = service
.apply(&second, false)
.await
.expect("replace configuration");
assert!(outcome.restart_required);
assert_eq!(service.document().await.expect("stored document"), second);
assert_eq!(running_snapshot.resolved.settings.mint_info.name, "first");
let next_startup = service.startup().await.expect("startup document");
assert_eq!(next_startup.resolved.settings.mint_info.name, "second");
assert_eq!(next_startup.state, DocumentState::Pending);
let rollback = service.rollback().await.expect("rollback pending document");
assert!(rollback.restart_required);
assert_eq!(service.document().await.expect("restored document"), first);
assert!(service
.has_pending_configuration()
.await
.expect("restored document requires activation"));
let _ = std::fs::remove_file(secret_path);
}
#[cfg(feature = "fakewallet")]
#[test]
fn startup_document_ignores_general_operational_environment_overrides() {
let _env_lock = crate::test_utils::env_lock();
let secret_path = crate::test_utils::unique_temp_path("startup_config_secret");
std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
std::env::set_var(crate::env_vars::ENV_LISTEN_PORT, "6553");
let document = format!(
r#"
[info]
listen_port = 8091
mnemonic = "file:{}"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
"#,
secret_path.display()
);
let resolved =
ConfigurationService::validate_document(&document).expect("validate startup document");
assert_eq!(resolved.settings.info.listen_port, 8091);
std::env::remove_var(crate::env_vars::ENV_LISTEN_PORT);
let _ = std::fs::remove_file(secret_path);
}
#[test]
fn configuration_without_payment_backend_is_rejected() {
let secret_path = crate::test_utils::unique_temp_path("no_payment_backend_secret");
std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
let document = format!(
r#"
[info]
mnemonic = "file:{}"
[payment_backend]
backend = "none"
[database]
engine = "sqlite"
"#,
secret_path.display()
);
let error = ConfigurationService::validate_document(&document)
.expect_err("configuration without a payment backend should fail");
assert!(
matches!(
&error,
ConfigurationServiceError::Validation(message)
if message.contains("At least one payment backend")
),
"unexpected error: {error}"
);
let _ = std::fs::remove_file(secret_path);
}
#[cfg(feature = "fakewallet")]
#[test]
fn selected_backend_without_its_configuration_section_is_rejected() {
let secret_path = crate::test_utils::unique_temp_path("missing_backend_section_secret");
std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
let document = format!(
r#"
[info]
mnemonic = "file:{}"
[payment_backend]
backend = "fakewallet"
[database]
engine = "sqlite"
"#,
secret_path.display()
);
let error = ConfigurationService::validate_document(&document)
.expect_err("selected backend without its config section should fail");
assert!(
matches!(
&error,
ConfigurationServiceError::Validation(message)
if message.contains(
"Fake wallet backend selected but [fake_wallet] config section is missing"
)
),
"unexpected error: {error}"
);
let _ = std::fs::remove_file(secret_path);
}
#[cfg(feature = "bdk")]
#[test]
fn opaque_environment_and_file_secrets_preserve_whitespace() {
let _env_lock = crate::test_utils::env_lock();
const PASSWORD_ENV: &str = "CDK_MINTD_TEST_WHITESPACE_BDK_PASSWORD";
let mnemonic_path = crate::test_utils::unique_temp_path("whitespace_secret_mnemonic");
let url_path = crate::test_utils::unique_temp_path("whitespace_secret_postgres_url");
std::fs::write(&mnemonic_path, TEST_MNEMONIC_ONE).expect("write signing secret");
std::fs::write(&url_path, "\tpostgresql://user:pass@localhost/cdk\n")
.expect("write postgres url secret");
std::env::set_var(PASSWORD_ENV, " password-secret ");
let document = format!(
r#"
[info]
mnemonic = "file:{}"
[payment_backend]
backend = "none"
[onchain]
onchain_backend = "bdk"
[bdk]
network = "regtest"
bitcoind_rpc_password = "env:{PASSWORD_ENV}"
[database]
engine = "postgres"
[database.postgres]
url = "file:{}"
"#,
mnemonic_path.display(),
url_path.display()
);
let resolved = ConfigurationService::validate_document(&document)
.expect("opaque secrets containing whitespace should validate");
let bdk = resolved
.settings
.bdk
.expect("bdk configuration should be present");
assert_eq!(
bdk.bitcoind_rpc_password.as_deref(),
Some(" password-secret ")
);
let postgres = resolved
.settings
.database
.postgres
.expect("postgres configuration should be present");
assert_eq!(postgres.url, "\tpostgresql://user:pass@localhost/cdk\n");
std::env::remove_var(PASSWORD_ENV);
let _ = std::fs::remove_file(mnemonic_path);
let _ = std::fs::remove_file(url_path);
}
#[cfg(feature = "fakewallet")]
#[test]
fn mnemonic_secret_trims_surrounding_whitespace() {
let mnemonic_path = crate::test_utils::unique_temp_path("trimmed_config_mnemonic");
std::fs::write(&mnemonic_path, format!(" {TEST_MNEMONIC_ONE}\n"))
.expect("write signing secret");
let resolved = ConfigurationService::validate_document(&document(
&format!("file:{}", mnemonic_path.display()),
"trimmed",
))
.expect("mnemonic surrounding whitespace should be normalized");
assert_eq!(
resolved.settings.info.mnemonic.as_deref(),
Some(TEST_MNEMONIC_ONE)
);
let _ = std::fs::remove_file(mnemonic_path);
}
#[cfg(all(feature = "sqlite", feature = "fakewallet"))]
#[tokio::test]
async fn apply_rejects_signer_and_primary_database_changes() {
let signer_path = crate::test_utils::unique_temp_path("signer_config_secret");
let postgres_path = crate::test_utils::unique_temp_path("postgres_config_secret");
std::fs::write(&signer_path, TEST_MNEMONIC_ONE).expect("write signing secret");
std::fs::write(&postgres_path, "postgresql://localhost/cdk-test")
.expect("write postgres secret");
let service = service().await;
let first = document(&format!("file:{}", signer_path.display()), "first");
service
.initialize(&first, None)
.await
.expect("initialize configuration");
std::fs::write(&signer_path, TEST_MNEMONIC_TWO).expect("replace signing secret");
assert!(matches!(
service.apply(&first, false).await,
Err(ConfigurationServiceError::SigningIdentityChange)
));
std::fs::write(&signer_path, TEST_MNEMONIC_ONE).expect("restore signing secret");
let postgres = format!(
r#"
[info]
mnemonic = "file:{}"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "postgres"
[database.postgres]
url = "file:{}"
"#,
signer_path.display(),
postgres_path.display()
);
assert!(matches!(
service.apply(&postgres, false).await,
Err(ConfigurationServiceError::PrimaryDatabaseChange)
));
let _ = std::fs::remove_file(signer_path);
let _ = std::fs::remove_file(postgres_path);
}
#[cfg(feature = "fakewallet")]
#[test]
fn seed_secret_and_inactive_sections_are_resolved_and_pruned() {
let _env_lock = crate::test_utils::env_lock();
const SEED_ENV: &str = "CDK_MINTD_TEST_CONFIG_SEED_SECRET";
let seed = "a".repeat(32);
std::env::set_var(SEED_ENV, &seed);
let document = format!(
r#"
[info]
seed = "env:{SEED_ENV}"
[mint_info]
name = "pruned"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
[database.postgres]
url = "env:SHOULD_BE_PRUNED"
[auth]
auth_enabled = false
openid_discovery = "https://example.com/.well-known/openid-configuration"
openid_client_id = "client"
[auth_database]
[auth_database.postgres]
url = "env:SHOULD_BE_PRUNED"
[signatory]
enabled = false
address = "127.0.0.1"
port = 15060
allow_insecure = true
"#
);
let resolved = ConfigurationService::validate_document(&document)
.expect("validate seed-backed document");
assert_eq!(resolved.settings.info.seed.as_deref(), Some(seed.as_str()));
assert!(resolved.settings.database.postgres.is_none());
assert!(resolved.settings.auth.is_none());
assert!(resolved.settings.auth_database.is_none());
assert!(resolved.settings.signatory.is_none());
assert!(format!("{resolved:?}").contains("redacted"));
let identity = discover_signing_identity(&resolved.settings).expect("seed identity");
assert!(!identity.fingerprint.is_empty());
std::env::remove_var(SEED_ENV);
}
#[cfg(feature = "fakewallet")]
#[test]
fn file_secret_errors_empty_env_name_and_missing_file_are_reported() {
let missing = crate::test_utils::unique_temp_path("missing_config_secret");
assert!(matches!(
ConfigurationService::validate_document(&document("env:", "empty-name")),
Err(ConfigurationServiceError::EmptySecret {
field: "info.mnemonic"
})
));
assert!(matches!(
ConfigurationService::validate_document(&document(
&format!("file:{}", missing.display()),
"missing-file"
)),
Err(ConfigurationServiceError::FileSecret {
field: "info.mnemonic",
..
})
));
}
#[cfg(feature = "fakewallet")]
#[tokio::test]
async fn authored_mint_pubkey_must_match_signer() {
let secret_one = crate::test_utils::unique_temp_path("pubkey_config_secret_one");
let secret_two = crate::test_utils::unique_temp_path("pubkey_config_secret_two");
std::fs::write(&secret_one, TEST_MNEMONIC_ONE).expect("write signing secret");
std::fs::write(&secret_two, TEST_MNEMONIC_TWO).expect("write other signing secret");
let identity = discover_signing_identity(
&ConfigurationService::validate_document(&document(
&format!("file:{}", secret_one.display()),
"identity",
))
.expect("resolve identity document")
.settings,
)
.expect("discover identity");
let other = discover_signing_identity(
&ConfigurationService::validate_document(&document(
&format!("file:{}", secret_two.display()),
"other",
))
.expect("resolve other document")
.settings,
)
.expect("discover other");
let mismatch = format!(
r#"
[info]
mnemonic = "file:{}"
[mint_info]
name = "mismatch"
pubkey = "{}"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
"#,
secret_one.display(),
other.pubkey
);
assert!(matches!(
ConfigurationService::validate_import(&mismatch).await,
Err(ConfigurationServiceError::SigningIdentityChange)
));
let matching = format!(
r#"
[info]
mnemonic = "file:{}"
[mint_info]
name = "match"
pubkey = "{}"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
"#,
secret_one.display(),
identity.pubkey
);
ConfigurationService::validate_import(&matching)
.await
.expect("matching pubkey");
let _ = std::fs::remove_file(secret_one);
let _ = std::fs::remove_file(secret_two);
}
#[test]
fn remote_signatory_requires_async_discovery() {
let settings = Settings {
signatory: Some(crate::config::Signatory {
enabled: true,
address: "127.0.0.1".to_owned(),
port: 15060,
tls_dir: None,
allow_insecure: true,
}),
..Default::default()
};
assert!(matches!(
discover_signing_identity(&settings),
Err(ConfigurationServiceError::SigningIdentity(message))
if message.contains("asynchronous")
));
}
#[test]
fn same_primary_database_compares_engine_and_postgres_fields() {
let sqlite = Database::default();
let other_sqlite = Database {
engine: DatabaseEngine::Sqlite,
postgres: Some(crate::config::PostgresConfig {
url: "postgresql://ignored".to_owned(),
..Default::default()
}),
};
assert!(same_primary_database(&sqlite, &other_sqlite));
let left = Database {
engine: DatabaseEngine::Postgres,
postgres: Some(crate::config::PostgresConfig {
url: "postgresql://a".to_owned(),
tls_mode: Some("disable".to_owned()),
max_connections: Some(5),
connection_timeout_seconds: Some(3),
}),
};
let right = Database {
engine: DatabaseEngine::Postgres,
postgres: Some(crate::config::PostgresConfig {
url: "postgresql://a".to_owned(),
tls_mode: Some("disable".to_owned()),
max_connections: Some(5),
connection_timeout_seconds: Some(3),
}),
};
assert!(same_primary_database(&left, &right));
let mut different = right.clone();
different.postgres.as_mut().expect("postgres").url = "postgresql://b".to_owned();
assert!(!same_primary_database(&left, &different));
assert!(!same_primary_database(
&left,
&Database {
engine: DatabaseEngine::Postgres,
postgres: None,
}
));
assert!(!same_primary_database(&sqlite, &left));
}
#[cfg(all(feature = "sqlite", feature = "fakewallet"))]
#[tokio::test]
async fn initialize_rejects_existing_mint_pubkey_mismatch_and_mark_applied_tracks_document() {
let secret_path = crate::test_utils::unique_temp_path("init_pubkey_config_secret");
std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
let service = service().await;
let first = document(&format!("file:{}", secret_path.display()), "first");
let identity = discover_signing_identity(
&ConfigurationService::validate_document(&first)
.expect("resolve")
.settings,
)
.expect("identity");
std::fs::write(&secret_path, TEST_MNEMONIC_TWO).expect("swap mnemonic");
let other = discover_signing_identity(
&ConfigurationService::validate_document(&document(
&format!("file:{}", secret_path.display()),
"other",
))
.expect("resolve other")
.settings,
)
.expect("other identity");
std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("restore mnemonic");
assert!(matches!(
service.initialize(&first, Some(other.pubkey)).await,
Err(ConfigurationServiceError::SigningIdentityChange)
));
service
.initialize(&first, Some(identity.pubkey))
.await
.expect("initialize with matching mint pubkey");
assert!(service
.has_pending_configuration()
.await
.expect("initialized document is pending"));
let initial_revision = service.startup().await.expect("initial startup").revision;
assert!(service
.mark_applied(initial_revision)
.await
.expect("mark applied"));
assert!(!service
.has_pending_configuration()
.await
.expect("applied document is active"));
let startup = service.startup().await.expect("startup");
assert_eq!(startup.state, DocumentState::Applied);
assert_eq!(startup.signing_identity.pubkey, identity.pubkey);
assert!(startup.remote_signatory.is_none());
let second = document(&format!("file:{}", secret_path.display()), "second");
service
.apply(&second, false)
.await
.expect("replace document");
assert!(service
.has_pending_configuration()
.await
.expect("replacement requires restart"));
assert!(!service
.mark_applied(initial_revision)
.await
.expect("stale document remains unapplied"));
let _ = std::fs::remove_file(secret_path);
}
}