1use std::fmt;
4use std::path::Path;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use bip39::Mnemonic;
9use bitcoin::bip32::Xpriv;
10use bitcoin::hashes::{sha256, Hash};
11use bitcoin::secp256k1::Secp256k1;
12use bitcoin::Network;
13use cdk_signatory::signatory::Signatory;
14use thiserror::Error;
15
16use crate::config::{Database, DatabaseEngine, Settings};
17use crate::config_store::{ConfigEnvelope, ConfigRepository, ConfigStoreError, DocumentState};
18use crate::secret::{SecretRef, SecretRefError, SecretResolveError};
19use crate::{BdkWalletPolicy, MintInitializationMode};
20
21const SIGNING_IDENTITY_DOMAIN: &[u8] = b"cdk-mintd/signing-identity/v1\0";
22const MIN_NEW_MINT_SEED_BYTES: usize = 32;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) struct SigningIdentity {
27 pub(crate) pubkey: cdk::nuts::PublicKey,
28 pub(crate) fingerprint: String,
29}
30
31#[derive(Clone)]
33pub struct ResolvedConfiguration {
34 pub document: String,
36 pub settings: Settings,
38}
39
40impl fmt::Debug for ResolvedConfiguration {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.debug_struct("ResolvedConfiguration")
43 .field("document", &self.document)
44 .field("settings", &"[resolved configuration redacted]")
45 .finish_non_exhaustive()
46 }
47}
48
49#[derive(Clone)]
51pub(crate) struct StartupConfiguration {
52 pub(crate) resolved: ResolvedConfiguration,
53 pub(crate) state: DocumentState,
54 pub(crate) revision: u64,
55 pub(crate) signing_identity: SigningIdentity,
56 pub(crate) remote_signatory: Option<Arc<cdk_signatory::SignatoryRpcClient>>,
57 pub(crate) bdk_wallet_policy: BdkWalletPolicy,
58}
59
60impl fmt::Debug for StartupConfiguration {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.debug_struct("StartupConfiguration")
63 .field("resolved", &self.resolved)
64 .field("state", &self.state)
65 .field("revision", &self.revision)
66 .field("signing_identity", &self.signing_identity)
67 .field("remote_signatory", &self.remote_signatory.is_some())
68 .field("bdk_wallet_policy", &self.bdk_wallet_policy)
69 .finish()
70 }
71}
72
73struct SigningIdentityResolution {
74 identity: SigningIdentity,
75 remote_signatory: Option<Arc<cdk_signatory::SignatoryRpcClient>>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct ApplyOutcome {
81 pub restart_required: bool,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct RollbackOutcome {
88 pub restart_required: bool,
90}
91
92#[derive(Debug, Error)]
94pub enum ConfigurationServiceError {
95 #[error("invalid mintd configuration document: {0}")]
97 Parse(#[from] config::ConfigError),
98
99 #[error("{field} must use an `env:VARIABLE` or `file:/absolute/path` secret reference")]
101 LiteralSecret {
102 field: &'static str,
104 },
105
106 #[error("could not resolve {field} from environment variable {name}")]
108 EnvironmentSecret {
109 field: &'static str,
111 name: String,
113 },
114
115 #[error("could not resolve {field} from secret file {}: {source}", path.display())]
117 FileSecret {
118 field: &'static str,
120 path: std::path::PathBuf,
122 #[source]
124 source: std::io::Error,
125 },
126
127 #[error("secret reference for {field} resolved to an empty value")]
129 EmptySecret {
130 field: &'static str,
132 },
133
134 #[error("invalid mintd configuration: {0}")]
136 Validation(String),
137
138 #[error("primary database settings do not match the bootstrap database settings")]
140 PrimaryDatabaseChange,
141
142 #[error("could not determine configured mint signing identity: {0}")]
144 SigningIdentity(String),
145
146 #[error(
148 "configured signing identity does not match this mint database; signer migration is not supported by config apply"
149 )]
150 SigningIdentityChange,
151
152 #[error(
154 "refusing new-mint initialization because the selected database contains existing mint identity or keyset state"
155 )]
156 NewMintHasExistingState,
157
158 #[error(
160 "new mint seed is too short ({actual} bytes); use a randomly generated seed of at least {minimum} bytes or a mnemonic; for an existing mint, preserve its original seed and use config init --existing-mint with its existing database"
161 )]
162 NewMintSeedTooShort {
163 actual: usize,
165 minimum: usize,
167 },
168
169 #[error(
171 "refusing existing-mint initialization because the selected database does not contain a mint identity; verify the database URL and work directory"
172 )]
173 ExistingMintMissingIdentity,
174
175 #[error(
177 "refusing existing-mint initialization because the selected embedded-signatory database does not contain keyset history; verify the database URL and work directory"
178 )]
179 ExistingMintMissingKeysets,
180
181 #[cfg(feature = "bdk")]
183 #[error("BDK wallet preflight failed: {0}")]
184 BdkWalletPreflight(#[source] cdk_bdk::Error),
185
186 #[error(transparent)]
188 Store(#[from] ConfigStoreError),
189}
190
191#[derive(Debug, Clone)]
193pub(crate) struct ConfigurationService {
194 repository: ConfigRepository,
195 primary_database: Database,
196}
197
198impl ConfigurationService {
199 pub(crate) fn new(repository: ConfigRepository, primary_database: Database) -> Self {
200 Self {
201 repository,
202 primary_database,
203 }
204 }
205
206 pub fn validate_document(
208 document: &str,
209 ) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
210 let mut settings = Settings::try_from_toml(document)?;
211 validate_secret_references(&settings)?;
212 prune_inactive_configuration(&mut settings);
213 resolve_secrets(&mut settings)?;
214 crate::validate_settings(&settings)
215 .map_err(|error| ConfigurationServiceError::Validation(error.to_string()))?;
216 Ok(ResolvedConfiguration {
217 document: document.to_owned(),
218 settings,
219 })
220 }
221
222 pub(crate) async fn validate_import(
224 document: &str,
225 ) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
226 Ok(Self::validated_import(document).await?.0)
227 }
228
229 pub(crate) async fn initialize(
231 &self,
232 document: &str,
233 mode: MintInitializationMode,
234 database_pubkey: Option<cdk::nuts::PublicKey>,
235 has_keysets: bool,
236 work_dir: &Path,
237 bdk_wallet_policy: BdkWalletPolicy,
238 ) -> Result<(), ConfigurationServiceError> {
239 let (resolved, signing_identity) = Self::validated_import(document).await?;
240 self.require_primary_database(&resolved.settings.database)?;
241 require_initialization_state(
242 mode,
243 database_pubkey,
244 has_keysets,
245 resolved.settings.enabled_signatory().is_some(),
246 )?;
247 if database_pubkey.is_some_and(|pubkey| pubkey != signing_identity.pubkey) {
248 return Err(ConfigurationServiceError::SigningIdentityChange);
249 }
250 if let Some(seed) = resolved.settings.info.seed.as_ref().filter(|seed| {
251 resolved.settings.enabled_signatory().is_none() && seed.len() < MIN_NEW_MINT_SEED_BYTES
252 }) {
253 match mode {
254 MintInitializationMode::New => {
255 return Err(ConfigurationServiceError::NewMintSeedTooShort {
256 actual: seed.len(),
257 minimum: MIN_NEW_MINT_SEED_BYTES,
258 });
259 }
260 MintInitializationMode::Existing => {
261 tracing::warn!(
262 "Importing an existing mint with a seed shorter than 32 bytes. Preserve \
263 the original seed during migration; changing it changes the mint keys. \
264 Use at least 32 randomly generated bytes for new mints."
265 );
266 }
267 }
268 }
269 let effective_bdk_wallet_policy = match mode {
270 MintInitializationMode::New if has_bdk_wallet(&resolved.settings) => {
271 BdkWalletPolicy::AllowNew
272 }
273 MintInitializationMode::New => BdkWalletPolicy::RequireExisting,
274 MintInitializationMode::Existing => {
275 require_existing_bdk_wallet(&resolved.settings, work_dir, bdk_wallet_policy)?
276 }
277 };
278 self.repository
279 .initialize(
280 ConfigEnvelope::new(resolved.document, signing_identity.fingerprint)
281 .with_new_bdk_wallet_allowed(
282 effective_bdk_wallet_policy == BdkWalletPolicy::AllowNew,
283 ),
284 )
285 .await?;
286 Ok(())
287 }
288
289 pub(crate) async fn apply(
291 &self,
292 document: &str,
293 validate_only: bool,
294 work_dir: &Path,
295 bdk_wallet_policy: BdkWalletPolicy,
296 ) -> Result<ApplyOutcome, ConfigurationServiceError> {
297 let (resolved, signing_identity) = Self::validated_import(document).await?;
298 self.require_primary_database(&resolved.settings.database)?;
299 let current = self.repository.active().await?;
300 if current.signing_identity != signing_identity.fingerprint {
301 return Err(ConfigurationServiceError::SigningIdentityChange);
302 }
303 let effective_bdk_wallet_policy =
304 require_existing_bdk_wallet(&resolved.settings, work_dir, bdk_wallet_policy)?;
305 if !validate_only {
306 self.repository
307 .replace_with_bdk_policy(
308 resolved.document,
309 &signing_identity.fingerprint,
310 effective_bdk_wallet_policy == BdkWalletPolicy::AllowNew,
311 )
312 .await?;
313 }
314 Ok(ApplyOutcome {
315 restart_required: !validate_only,
316 })
317 }
318
319 pub(crate) async fn startup(&self) -> Result<StartupConfiguration, ConfigurationServiceError> {
321 let envelope = self.repository.active().await?;
322 let resolved = Self::validate_document(&envelope.toml)?;
323 self.require_primary_database(&resolved.settings.database)?;
324 let signing_resolution = resolve_signing_identity_async(&resolved.settings).await?;
325 validate_authored_mint_pubkey(&resolved.settings, &signing_resolution.identity)?;
326 if envelope.signing_identity != signing_resolution.identity.fingerprint {
327 return Err(ConfigurationServiceError::SigningIdentityChange);
328 }
329 Ok(StartupConfiguration {
330 resolved,
331 state: envelope.state(),
332 revision: envelope.revision,
333 signing_identity: signing_resolution.identity,
334 remote_signatory: signing_resolution.remote_signatory,
335 bdk_wallet_policy: match envelope.allow_new_bdk_wallet {
336 true => BdkWalletPolicy::AllowNew,
337 false => BdkWalletPolicy::RequireExisting,
338 },
339 })
340 }
341
342 pub(crate) async fn document(&self) -> Result<String, ConfigurationServiceError> {
344 Ok(self.repository.active().await?.toml)
345 }
346
347 #[cfg(any(feature = "management-rpc", test))]
349 pub(crate) async fn has_pending_configuration(
350 &self,
351 ) -> Result<bool, ConfigurationServiceError> {
352 Ok(matches!(
353 self.repository.active().await?.state(),
354 DocumentState::Pending
355 ))
356 }
357
358 pub(crate) async fn mark_applied(
360 &self,
361 expected_revision: u64,
362 ) -> Result<bool, ConfigurationServiceError> {
363 Ok(self.repository.mark_applied(expected_revision).await?)
364 }
365
366 pub(crate) async fn rollback(&self) -> Result<RollbackOutcome, ConfigurationServiceError> {
368 Ok(RollbackOutcome {
369 restart_required: self.repository.rollback().await?,
370 })
371 }
372
373 fn require_primary_database(
374 &self,
375 configured: &Database,
376 ) -> Result<(), ConfigurationServiceError> {
377 if !same_primary_database(configured, &self.primary_database) {
378 return Err(ConfigurationServiceError::PrimaryDatabaseChange);
379 }
380 Ok(())
381 }
382
383 async fn validated_import(
384 document: &str,
385 ) -> Result<(ResolvedConfiguration, SigningIdentity), ConfigurationServiceError> {
386 let resolved = Self::validate_document(document)?;
387 let signing_identity = discover_signing_identity_async(&resolved.settings).await?;
388 validate_authored_mint_pubkey(&resolved.settings, &signing_identity)?;
389 Ok((resolved, signing_identity))
390 }
391}
392
393#[cfg(feature = "bdk")]
394fn has_bdk_wallet(settings: &Settings) -> bool {
395 settings.bdk.is_some()
396}
397
398#[cfg(not(feature = "bdk"))]
399fn has_bdk_wallet(_settings: &Settings) -> bool {
400 false
401}
402
403#[cfg(feature = "bdk")]
404pub(crate) fn require_existing_bdk_wallet(
405 settings: &Settings,
406 work_dir: &Path,
407 bdk_wallet_policy: BdkWalletPolicy,
408) -> Result<BdkWalletPolicy, ConfigurationServiceError> {
409 let Some(bdk) = settings.bdk.as_ref() else {
410 return Ok(BdkWalletPolicy::RequireExisting);
411 };
412 let wallet_path = work_dir.join("bdk_wallet/bdk_wallet.sqlite");
413 if bdk_wallet_policy == BdkWalletPolicy::AllowNew {
414 match std::fs::metadata(&wallet_path) {
415 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
416 bdk.validate_wallet_identity()
417 .map_err(ConfigurationServiceError::BdkWalletPreflight)?;
418 return Ok(BdkWalletPolicy::AllowNew);
419 }
420 Err(error) => {
421 return Err(ConfigurationServiceError::BdkWalletPreflight(
422 cdk_bdk::Error::Io(error),
423 ));
424 }
425 Ok(_) => {}
426 }
427 }
428 bdk.validate_existing_wallet(work_dir)
429 .map_err(ConfigurationServiceError::BdkWalletPreflight)?;
430 Ok(BdkWalletPolicy::RequireExisting)
431}
432
433#[cfg(not(feature = "bdk"))]
434pub(crate) fn require_existing_bdk_wallet(
435 _settings: &Settings,
436 _work_dir: &Path,
437 _bdk_wallet_policy: BdkWalletPolicy,
438) -> Result<BdkWalletPolicy, ConfigurationServiceError> {
439 Ok(BdkWalletPolicy::RequireExisting)
440}
441
442fn require_initialization_state(
443 mode: MintInitializationMode,
444 database_pubkey: Option<cdk::nuts::PublicKey>,
445 has_keysets: bool,
446 uses_remote_signatory: bool,
447) -> Result<(), ConfigurationServiceError> {
448 match mode {
449 MintInitializationMode::New if database_pubkey.is_some() || has_keysets => {
450 Err(ConfigurationServiceError::NewMintHasExistingState)
451 }
452 MintInitializationMode::Existing if database_pubkey.is_none() => {
453 Err(ConfigurationServiceError::ExistingMintMissingIdentity)
454 }
455 MintInitializationMode::Existing if !uses_remote_signatory && !has_keysets => {
456 Err(ConfigurationServiceError::ExistingMintMissingKeysets)
457 }
458 MintInitializationMode::New | MintInitializationMode::Existing => Ok(()),
459 }
460}
461
462pub(crate) fn discover_signing_identity(
463 settings: &Settings,
464) -> Result<SigningIdentity, ConfigurationServiceError> {
465 let pubkey = if settings.enabled_signatory().is_some() {
466 return Err(ConfigurationServiceError::SigningIdentity(
467 "remote signatory identity requires asynchronous validation".to_owned(),
468 ));
469 } else if let Some(seed) = settings
470 .info
471 .seed
472 .as_deref()
473 .filter(|seed| !seed.is_empty())
474 {
475 root_pubkey(seed.as_bytes())?
476 } else if let Some(mnemonic) = settings.info.mnemonic.as_deref() {
477 let mnemonic = Mnemonic::from_str(mnemonic)
478 .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?;
479 root_pubkey(&mnemonic.to_seed_normalized(""))?
480 } else {
481 return Err(ConfigurationServiceError::SigningIdentity(
482 "no local signing source is configured".to_owned(),
483 ));
484 };
485 Ok(signing_identity_from_pubkey(pubkey))
486}
487
488pub(crate) async fn discover_signing_identity_async(
490 settings: &Settings,
491) -> Result<SigningIdentity, ConfigurationServiceError> {
492 Ok(resolve_signing_identity_async(settings).await?.identity)
493}
494
495async fn resolve_signing_identity_async(
496 settings: &Settings,
497) -> Result<SigningIdentityResolution, ConfigurationServiceError> {
498 if let Some(signatory) = settings.enabled_signatory() {
499 let client = Arc::new(
500 cdk_signatory::SignatoryRpcClient::new(
501 &signatory.address,
502 signatory.port,
503 signatory.tls_dir.clone(),
504 )
505 .await
506 .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?,
507 );
508 let pubkey = client
509 .keysets()
510 .await
511 .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?
512 .pubkey;
513 Ok(SigningIdentityResolution {
514 identity: signing_identity_from_pubkey(pubkey),
515 remote_signatory: Some(client),
516 })
517 } else {
518 Ok(SigningIdentityResolution {
519 identity: discover_signing_identity(settings)?,
520 remote_signatory: None,
521 })
522 }
523}
524
525fn root_pubkey(seed: &[u8]) -> Result<cdk::nuts::PublicKey, ConfigurationServiceError> {
526 let secp = Secp256k1::new();
527 let xpriv = Xpriv::new_master(Network::Bitcoin, seed)
528 .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?;
529 Ok(xpriv.to_keypair(&secp).public_key().into())
530}
531
532fn signing_identity_from_pubkey(pubkey: cdk::nuts::PublicKey) -> SigningIdentity {
533 let mut input = SIGNING_IDENTITY_DOMAIN.to_vec();
534 input.extend_from_slice(&pubkey.to_bytes());
535 SigningIdentity {
536 pubkey,
537 fingerprint: sha256::Hash::hash(&input).to_string(),
538 }
539}
540
541fn validate_authored_mint_pubkey(
542 settings: &Settings,
543 signing_identity: &SigningIdentity,
544) -> Result<(), ConfigurationServiceError> {
545 if settings
546 .mint_info
547 .pubkey
548 .is_some_and(|pubkey| pubkey != signing_identity.pubkey)
549 {
550 return Err(ConfigurationServiceError::SigningIdentityChange);
551 }
552 Ok(())
553}
554
555fn same_primary_database(configured: &Database, bootstrap: &Database) -> bool {
556 if configured.engine != bootstrap.engine {
557 return false;
558 }
559 if configured.engine != DatabaseEngine::Postgres {
560 return true;
561 }
562 match (&configured.postgres, &bootstrap.postgres) {
563 (Some(configured), Some(bootstrap)) => {
564 #[cfg(feature = "postgres")]
565 let same_tls_policy = {
566 let connection = |config: &crate::config::PostgresConfig| {
567 cdk_postgres::PgConfig::new(&config.url, config.tls_mode.as_deref(), None, None)
568 };
569 connection(configured)
570 .has_same_tls_policy(&connection(bootstrap))
571 .unwrap_or(false)
572 };
573 #[cfg(not(feature = "postgres"))]
574 let same_tls_policy = configured.tls_mode == bootstrap.tls_mode;
575
576 configured.url == bootstrap.url
577 && same_tls_policy
578 && configured.max_connections == bootstrap.max_connections
579 && configured.connection_timeout_seconds == bootstrap.connection_timeout_seconds
580 }
581 _ => false,
582 }
583}
584
585pub(crate) fn prune_inactive_configuration(settings: &mut Settings) {
586 if settings.database.engine != DatabaseEngine::Postgres {
587 settings.database.postgres = None;
588 }
589 if settings
590 .auth
591 .as_ref()
592 .is_some_and(|auth| !auth.auth_enabled)
593 {
594 settings.auth = None;
595 }
596 if settings.auth.is_none() || settings.database.engine != DatabaseEngine::Postgres {
597 settings.auth_database = None;
598 }
599 if settings
600 .signatory
601 .as_ref()
602 .is_some_and(|signatory| !signatory.enabled)
603 {
604 settings.signatory = None;
605 }
606
607 #[cfg(feature = "ldk-node")]
608 if !settings
609 .payment_backend
610 .iter()
611 .any(|backend| backend.backend == crate::config::PaymentBackendType::LdkNode)
612 {
613 settings.ldk_node = None;
614 }
615 #[cfg(feature = "bdk")]
616 if !settings
617 .onchain
618 .as_ref()
619 .is_some_and(|onchain| onchain.onchain_backend == crate::config::OnchainBackend::Bdk)
620 {
621 settings.bdk = None;
622 }
623}
624
625fn validate_secret_references(settings: &Settings) -> Result<(), ConfigurationServiceError> {
626 validate_optional_secret_reference(settings.info.seed.as_deref(), "info.seed")?;
627 validate_optional_secret_reference(settings.info.mnemonic.as_deref(), "info.mnemonic")?;
628
629 if let Some(postgres) = settings.database.postgres.as_ref() {
630 validate_secret_reference(&postgres.url, "database.postgres.url")?;
631 }
632 if let Some(postgres) = settings
633 .auth_database
634 .as_ref()
635 .and_then(|database| database.postgres.as_ref())
636 {
637 validate_secret_reference(&postgres.url, "auth_database.postgres.url")?;
638 }
639 #[cfg(feature = "bdk")]
640 if let Some(bdk) = settings.bdk.as_ref() {
641 validate_optional_secret_reference(
642 bdk.bitcoind_rpc_password.as_deref(),
643 "bdk.bitcoind_rpc_password",
644 )?;
645 validate_optional_secret_reference(bdk.mnemonic.as_deref(), "bdk.mnemonic")?;
646 }
647 #[cfg(feature = "ldk-node")]
648 if let Some(ldk_node) = settings.ldk_node.as_ref() {
649 validate_optional_secret_reference(
650 ldk_node.bitcoind_rpc_password.as_deref(),
651 "ldk_node.bitcoind_rpc_password",
652 )?;
653 validate_optional_secret_reference(
654 ldk_node.ldk_node_mnemonic.as_deref(),
655 "ldk_node.ldk_node_mnemonic",
656 )?;
657 }
658 #[cfg(feature = "redis")]
659 if let cdk_axum::cache::Backend::Redis(redis) = &settings.info.http_cache.backend {
660 validate_secret_reference(
661 &redis.connection_string,
662 "info.http_cache.connection_string",
663 )?;
664 if let Some(cluster_nodes) = redis.cluster_nodes.as_ref() {
665 for node in cluster_nodes {
666 validate_secret_reference(node, "info.http_cache.cluster_nodes")?;
667 }
668 }
669 }
670 Ok(())
671}
672
673fn validate_optional_secret_reference(
674 value: Option<&str>,
675 field: &'static str,
676) -> Result<(), ConfigurationServiceError> {
677 if let Some(value) = value {
678 validate_secret_reference(value, field)?;
679 }
680 Ok(())
681}
682
683fn validate_secret_reference(
684 value: &str,
685 field: &'static str,
686) -> Result<(), ConfigurationServiceError> {
687 if value.is_empty() {
688 return Ok(());
689 }
690 SecretRef::parse(value).map_err(|error| match error {
691 SecretRefError::EmptyEnvironmentName => ConfigurationServiceError::EmptySecret { field },
692 SecretRefError::Literal
693 | SecretRefError::EmptyFilePath
694 | SecretRefError::RelativeFilePath { .. } => {
695 ConfigurationServiceError::LiteralSecret { field }
696 }
697 })?;
698 Ok(())
699}
700
701fn resolve_secrets(settings: &mut Settings) -> Result<(), ConfigurationServiceError> {
702 if settings.enabled_signatory().is_none() {
703 resolve_optional_secret(&mut settings.info.seed, "info.seed")?;
704 resolve_optional_trimmed_secret(&mut settings.info.mnemonic, "info.mnemonic")?;
705 }
706
707 if let Some(postgres) = settings.database.postgres.as_mut() {
708 resolve_secret(&mut postgres.url, "database.postgres.url")?;
709 }
710 if let Some(postgres) = settings
711 .auth_database
712 .as_mut()
713 .and_then(|database| database.postgres.as_mut())
714 {
715 resolve_secret(&mut postgres.url, "auth_database.postgres.url")?;
716 }
717 #[cfg(feature = "bdk")]
718 if let Some(bdk) = settings.bdk.as_mut() {
719 resolve_optional_secret(&mut bdk.bitcoind_rpc_password, "bdk.bitcoind_rpc_password")?;
720 resolve_optional_trimmed_secret(&mut bdk.mnemonic, "bdk.mnemonic")?;
721 }
722 #[cfg(feature = "ldk-node")]
723 if let Some(ldk_node) = settings.ldk_node.as_mut() {
724 resolve_optional_secret(
725 &mut ldk_node.bitcoind_rpc_password,
726 "ldk_node.bitcoind_rpc_password",
727 )?;
728 resolve_optional_trimmed_secret(
729 &mut ldk_node.ldk_node_mnemonic,
730 "ldk_node.ldk_node_mnemonic",
731 )?;
732 }
733 #[cfg(feature = "redis")]
734 if let cdk_axum::cache::Backend::Redis(redis) = &mut settings.info.http_cache.backend {
735 resolve_secret(
736 &mut redis.connection_string,
737 "info.http_cache.connection_string",
738 )?;
739 if let Some(cluster_nodes) = redis.cluster_nodes.as_mut() {
740 for node in cluster_nodes {
741 resolve_secret(node, "info.http_cache.cluster_nodes")?;
742 }
743 }
744 }
745 Ok(())
746}
747
748fn resolve_optional_secret(
749 value: &mut Option<String>,
750 field: &'static str,
751) -> Result<(), ConfigurationServiceError> {
752 if let Some(value) = value.as_mut() {
753 resolve_secret(value, field)?;
754 }
755 Ok(())
756}
757
758fn resolve_optional_trimmed_secret(
759 value: &mut Option<String>,
760 field: &'static str,
761) -> Result<(), ConfigurationServiceError> {
762 if let Some(value) = value.as_mut() {
763 resolve_trimmed_secret(value, field)?;
764 }
765 Ok(())
766}
767
768fn resolve_secret(
769 value: &mut String,
770 field: &'static str,
771) -> Result<(), ConfigurationServiceError> {
772 resolve_secret_with(value, field, |resolved| resolved)
773}
774
775fn resolve_trimmed_secret(
776 value: &mut String,
777 field: &'static str,
778) -> Result<(), ConfigurationServiceError> {
779 resolve_secret_with(value, field, |resolved| resolved.trim().to_owned())
780}
781
782fn resolve_secret_with(
783 value: &mut String,
784 field: &'static str,
785 normalize: impl FnOnce(String) -> String,
786) -> Result<(), ConfigurationServiceError> {
787 validate_secret_reference(value, field)?;
788 if value.is_empty() {
789 return Ok(());
790 }
791 let reference = SecretRef::parse(value).map_err(|error| match error {
792 SecretRefError::EmptyEnvironmentName => ConfigurationServiceError::EmptySecret { field },
793 SecretRefError::Literal
794 | SecretRefError::EmptyFilePath
795 | SecretRefError::RelativeFilePath { .. } => {
796 ConfigurationServiceError::LiteralSecret { field }
797 }
798 })?;
799 let resolved = reference.resolve().map_err(|error| match error {
800 SecretResolveError::Environment { name } => {
801 ConfigurationServiceError::EnvironmentSecret { field, name }
802 }
803 SecretResolveError::File { path, source } => ConfigurationServiceError::FileSecret {
804 field,
805 path,
806 source,
807 },
808 })?;
809 let resolved = normalize(resolved);
810 if resolved.is_empty() {
811 return Err(ConfigurationServiceError::EmptySecret { field });
812 }
813 *value = resolved;
814 Ok(())
815}
816
817#[cfg(test)]
818mod tests {
819 #[cfg(feature = "sqlite")]
820 use std::sync::Arc;
821
822 #[cfg(feature = "sqlite")]
823 use cdk_sqlite::mint::memory;
824
825 use super::*;
826
827 const TEST_MNEMONIC_ONE: &str =
828 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
829 const TEST_MNEMONIC_TWO: &str =
830 "legal winner thank year wave sausage worth useful legal winner thank yellow";
831
832 #[cfg(feature = "fakewallet")]
833 fn document(secret_reference: &str, name: &str) -> String {
834 format!(
835 r#"
836[info]
837mnemonic = "{secret_reference}"
838
839[mint_info]
840name = "{name}"
841
842[payment_backend]
843backend = "fakewallet"
844
845[fake_wallet]
846
847[database]
848engine = "sqlite"
849"#
850 )
851 }
852
853 #[cfg(all(feature = "postgres", feature = "fakewallet"))]
854 #[tokio::test]
855 async fn config_validate_checks_postgres_tls_without_connecting() {
856 let mnemonic_path = crate::test_utils::unique_temp_path("validate_tls_mnemonic");
857 let primary_path = crate::test_utils::unique_temp_path("validate_tls_primary_url");
858 let auth_path = crate::test_utils::unique_temp_path("validate_tls_auth_url");
859 std::fs::write(&mnemonic_path, TEST_MNEMONIC_ONE).expect("write mnemonic");
860 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listen");
861 listener
862 .set_nonblocking(true)
863 .expect("nonblocking listener");
864 let port = listener.local_addr().expect("listener address").port();
865 let url = format!("postgres://mint:secret-password@127.0.0.1:{port}/cdk");
866 let base = document(&format!("file:{}", mnemonic_path.display()), "validate TLS")
867 .replace("engine = \"sqlite\"", "engine = \"postgres\"");
868
869 for section in ["database.postgres", "auth_database.postgres"] {
870 for (explicit, suffix, valid) in [
871 ("tls_mode = \"verify-full\"", "", true),
872 ("tls_mode = \"requre\"", "", false), ("", "?sslmode=requre", false), ] {
875 let (primary_mode, auth_mode) = match section {
876 "database.postgres" => {
877 std::fs::write(&primary_path, format!("{url}{suffix}"))
878 .expect("primary URL");
879 std::fs::write(&auth_path, &url).expect("auth URL");
880 (explicit, "")
881 }
882 _ => {
883 std::fs::write(&primary_path, &url).expect("primary URL");
884 std::fs::write(&auth_path, format!("{url}{suffix}")).expect("auth URL");
885 ("", explicit)
886 }
887 };
888 let document = format!(
889 r#"{base}
890[database.postgres]
891url = "file:{}"
892{primary_mode}
893
894[auth]
895auth_enabled = true
896openid_discovery = "https://issuer.example.com/.well-known/openid-configuration"
897openid_client_id = "mint"
898
899[auth_database.postgres]
900url = "file:{}"
901{auth_mode}
902"#,
903 primary_path.display(),
904 auth_path.display(),
905 );
906 let result = crate::validate_configuration_document(&document).await;
907 match valid {
908 true => result.expect("valid PostgreSQL settings require no connection"),
909 false => {
910 let message = result.expect_err("invalid PostgreSQL settings").to_string();
911 assert!(message.contains(&format!("[{section}]")), "{message}");
912 assert!(!message.contains("secret-password"), "{message}");
913 }
914 }
915 let error = listener
916 .accept()
917 .expect_err("validation must not connect to PostgreSQL");
918 assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
919 }
920 }
921 for path in [mnemonic_path, primary_path, auth_path] {
922 std::fs::remove_file(path).expect("remove test secret");
923 }
924 }
925
926 #[cfg(feature = "fakewallet")]
927 fn remote_signatory_document(info_fields: &str, extra_sections: &str) -> String {
928 format!(
929 r#"
930[info]
931{info_fields}
932
933[signatory]
934enabled = true
935allow_insecure = true
936
937[payment_backend]
938backend = "fakewallet"
939
940[fake_wallet]
941
942[database]
943engine = "sqlite"
944
945{extra_sections}
946"#
947 )
948 }
949
950 #[cfg(feature = "sqlite")]
951 async fn service() -> ConfigurationService {
952 let database = Arc::new(memory::empty().await.expect("in-memory database"));
953 ConfigurationService::new(ConfigRepository::new(database), Database::default())
954 }
955
956 #[test]
957 fn literal_signing_secret_is_rejected() {
958 let error = ConfigurationService::validate_document(
959 r#"
960[info]
961mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
962"#,
963 )
964 .expect_err("literal mnemonic should fail");
965 assert!(matches!(
966 error,
967 ConfigurationServiceError::LiteralSecret {
968 field: "info.mnemonic"
969 }
970 ));
971 }
972
973 #[cfg(feature = "fakewallet")]
974 #[test]
975 fn remote_signatory_rejects_local_signing_material_without_resolving_it() {
976 let missing_secret =
977 crate::test_utils::unique_temp_path("remote_signatory_unused_local_secret");
978 let document = format!(
979 r#"
980[info]
981mnemonic = "file:{}"
982
983[signatory]
984enabled = true
985allow_insecure = true
986
987[payment_backend]
988backend = "fakewallet"
989
990[fake_wallet]
991
992[database]
993engine = "sqlite"
994"#,
995 missing_secret.display()
996 );
997 let error = ConfigurationService::validate_document(&document)
998 .expect_err("remote and local signing sources should conflict");
999 assert!(
1000 matches!(
1001 &error,
1002 ConfigurationServiceError::Validation(message)
1003 if message.contains("Remote signatory configuration cannot include")
1004 ),
1005 "unexpected error: {error}"
1006 );
1007 }
1008
1009 #[cfg(feature = "fakewallet")]
1010 #[test]
1011 fn remote_signatory_configuration_does_not_require_local_signing_material() {
1012 let resolved = ConfigurationService::validate_document(
1013 r#"
1014[signatory]
1015enabled = true
1016allow_insecure = true
1017
1018[payment_backend]
1019backend = "fakewallet"
1020
1021[fake_wallet]
1022
1023[database]
1024engine = "sqlite"
1025"#,
1026 )
1027 .expect("remote signatory should be a complete signing source");
1028 assert!(resolved.settings.info.seed.is_none());
1029 assert!(resolved.settings.info.mnemonic.is_none());
1030 }
1031
1032 #[cfg(feature = "fakewallet")]
1033 #[test]
1034 fn literal_secrets_in_inactive_or_skipped_sections_are_rejected() {
1035 let assert_rejected = |document: String, expected_field| {
1036 let error = ConfigurationService::validate_document(&document)
1037 .expect_err("literal secret should be rejected before pruning");
1038 assert!(
1039 matches!(
1040 error,
1041 ConfigurationServiceError::LiteralSecret { field }
1042 if field == expected_field
1043 ),
1044 "unexpected error for {expected_field}: {error}"
1045 );
1046 };
1047
1048 assert_rejected(
1049 remote_signatory_document(r#"seed = "plaintext-secret""#, ""),
1050 "info.seed",
1051 );
1052 assert_rejected(
1053 remote_signatory_document(
1054 "",
1055 r#"
1056[database.postgres]
1057url = "postgresql://operator:plaintext-secret@localhost/cdk"
1058"#,
1059 ),
1060 "database.postgres.url",
1061 );
1062 assert_rejected(
1063 remote_signatory_document(
1064 "",
1065 r#"
1066[auth_database.postgres]
1067url = "postgresql://operator:plaintext-secret@localhost/cdk"
1068"#,
1069 ),
1070 "auth_database.postgres.url",
1071 );
1072
1073 #[cfg(feature = "bdk")]
1074 assert_rejected(
1075 remote_signatory_document(
1076 "",
1077 r#"
1078[bdk]
1079mnemonic = "plaintext-secret"
1080"#,
1081 ),
1082 "bdk.mnemonic",
1083 );
1084 #[cfg(feature = "ldk-node")]
1085 assert_rejected(
1086 remote_signatory_document(
1087 "",
1088 r#"
1089[ldk_node]
1090ldk_node_mnemonic = "plaintext-secret"
1091"#,
1092 ),
1093 "ldk_node.ldk_node_mnemonic",
1094 );
1095 #[cfg(feature = "redis")]
1096 assert_rejected(
1097 remote_signatory_document(
1098 "",
1099 r#"
1100[info.http_cache]
1101backend = "redis"
1102connection_string = "redis://operator:plaintext-secret@localhost"
1103"#,
1104 ),
1105 "info.http_cache.connection_string",
1106 );
1107 #[cfg(feature = "redis")]
1108 assert_rejected(
1109 remote_signatory_document(
1110 "",
1111 r#"
1112[info.http_cache]
1113backend = "redis"
1114connection_string = ""
1115cluster_nodes = ["redis://operator:plaintext-secret@localhost"]
1116"#,
1117 ),
1118 "info.http_cache.cluster_nodes",
1119 );
1120 }
1121
1122 #[cfg(feature = "fakewallet")]
1123 #[test]
1124 fn inactive_secret_references_are_validated_but_not_resolved() {
1125 let _env_lock = crate::test_utils::env_lock();
1126 const MISSING: &str = "CDK_MINTD_TEST_MISSING_INACTIVE_POSTGRES_SECRET";
1127 std::env::remove_var(MISSING);
1128 let document = remote_signatory_document(
1129 "",
1130 &format!(
1131 r#"
1132[database.postgres]
1133url = "env:{MISSING}"
1134"#
1135 ),
1136 );
1137
1138 ConfigurationService::validate_document(&document)
1139 .expect("inactive valid reference should not be resolved");
1140 }
1141
1142 #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1143 #[tokio::test]
1144 async fn rejected_inactive_literal_secret_is_not_persisted() {
1145 let service = service().await;
1146 let document = remote_signatory_document(
1147 "",
1148 r#"
1149[database.postgres]
1150url = "postgresql://operator:plaintext-secret@localhost/cdk"
1151"#,
1152 );
1153
1154 assert!(matches!(
1155 service
1156 .initialize(
1157 &document,
1158 MintInitializationMode::New,
1159 None,
1160 false,
1161 Path::new("."),
1162 BdkWalletPolicy::RequireExisting,
1163 )
1164 .await,
1165 Err(ConfigurationServiceError::LiteralSecret {
1166 field: "database.postgres.url"
1167 })
1168 ));
1169 assert!(matches!(
1170 service.document().await,
1171 Err(ConfigurationServiceError::Store(
1172 ConfigStoreError::NotInitialized
1173 ))
1174 ));
1175 }
1176
1177 #[cfg(feature = "fakewallet")]
1178 #[test]
1179 fn missing_empty_and_relative_secret_references_are_rejected() {
1180 let _env_lock = crate::test_utils::env_lock();
1181 const MISSING: &str = "CDK_MINTD_TEST_MISSING_CONFIG_SECRET";
1182 const EMPTY: &str = "CDK_MINTD_TEST_EMPTY_CONFIG_SECRET";
1183 std::env::remove_var(MISSING);
1184 std::env::set_var(EMPTY, " ");
1185
1186 assert!(matches!(
1187 ConfigurationService::validate_document(&document(
1188 &format!("env:{MISSING}"),
1189 "missing"
1190 )),
1191 Err(ConfigurationServiceError::EnvironmentSecret { .. })
1192 ));
1193 assert!(matches!(
1194 ConfigurationService::validate_document(&document(&format!("env:{EMPTY}"), "empty")),
1195 Err(ConfigurationServiceError::EmptySecret { .. })
1196 ));
1197 assert!(matches!(
1198 ConfigurationService::validate_document(&document("file:relative/secret", "relative")),
1199 Err(ConfigurationServiceError::LiteralSecret { .. })
1200 ));
1201
1202 std::env::remove_var(EMPTY);
1203 }
1204
1205 #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1206 #[tokio::test]
1207 async fn new_mint_initialization_checks_resolved_seed_byte_length() {
1208 let secret_path = crate::test_utils::unique_temp_path("new_mint_seed_length");
1209 let document = document(&format!("file:{}", secret_path.display()), "new-mint")
1210 .replace("mnemonic =", "seed =");
1211
1212 for seed in [
1213 "a".to_string(),
1214 "a".repeat(31),
1215 "a".repeat(32),
1216 "é".repeat(16),
1217 ] {
1218 std::fs::write(&secret_path, &seed).expect("write seed");
1219 let service = service().await;
1220 let result = service
1221 .initialize(
1222 &document,
1223 MintInitializationMode::New,
1224 None,
1225 false,
1226 Path::new("."),
1227 BdkWalletPolicy::RequireExisting,
1228 )
1229 .await;
1230
1231 match seed.len() {
1232 0..=31 => {
1233 assert!(matches!(
1234 result,
1235 Err(ConfigurationServiceError::NewMintSeedTooShort {
1236 actual,
1237 minimum: 32,
1238 }) if actual == seed.len()
1239 ));
1240 assert!(matches!(
1241 service.document().await,
1242 Err(ConfigurationServiceError::Store(
1243 ConfigStoreError::NotInitialized
1244 ))
1245 ));
1246 }
1247 _ => {
1248 result.expect("32-byte seed should initialize a new mint");
1249 assert_eq!(service.document().await.expect("stored document"), document);
1250 }
1251 }
1252 }
1253
1254 std::fs::remove_file(secret_path).expect("remove seed");
1255 }
1256
1257 #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1258 #[tokio::test]
1259 async fn existing_short_seed_survives_initialization_apply_and_restart() {
1260 let secret_path = crate::test_utils::unique_temp_path("existing_short_seed");
1261 let seed = "legacy";
1262 std::fs::write(&secret_path, seed).expect("write original seed");
1263 let first = document(&format!("file:{}", secret_path.display()), "existing")
1264 .replace("mnemonic =", "seed =");
1265 let resolved = ConfigurationService::validate_import(&first)
1266 .await
1267 .expect("short seed should validate for import");
1268 let identity = discover_signing_identity(&resolved.settings).expect("original identity");
1269 let service = service().await;
1270
1271 service
1272 .initialize(
1273 &first,
1274 MintInitializationMode::Existing,
1275 Some(identity.pubkey),
1276 true,
1277 Path::new("."),
1278 BdkWalletPolicy::RequireExisting,
1279 )
1280 .await
1281 .expect("import existing mint with a short seed");
1282 let startup = service.startup().await.expect("first startup");
1283 assert_eq!(startup.resolved.settings.info.seed.as_deref(), Some(seed));
1284 assert_eq!(startup.signing_identity, identity);
1285 service
1286 .mark_applied(startup.revision)
1287 .await
1288 .expect("mark applied");
1289
1290 let second = first.replace("name = \"existing\"", "name = \"updated\"");
1291 service
1292 .apply(
1293 &second,
1294 false,
1295 Path::new("."),
1296 BdkWalletPolicy::RequireExisting,
1297 )
1298 .await
1299 .expect("update configuration with the original short seed");
1300 let restarted = service.startup().await.expect("restart");
1301 assert_eq!(restarted.resolved.settings.info.seed.as_deref(), Some(seed));
1302 assert_eq!(restarted.signing_identity, identity);
1303 assert_eq!(restarted.resolved.document, second);
1304
1305 std::fs::remove_file(secret_path).expect("remove seed");
1306 }
1307
1308 #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1309 #[tokio::test]
1310 async fn initialize_apply_and_validate_only_use_one_record() {
1311 let secret_path = crate::test_utils::unique_temp_path("atomic_config_secret");
1312 std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1313 let secret_reference = format!("file:{}", secret_path.display());
1314 let service = service().await;
1315 let first = document(&secret_reference, "first");
1316 let second = document(&secret_reference, "second");
1317
1318 service
1319 .initialize(
1320 &first,
1321 MintInitializationMode::New,
1322 None,
1323 false,
1324 Path::new("."),
1325 BdkWalletPolicy::RequireExisting,
1326 )
1327 .await
1328 .expect("initialize configuration");
1329 assert!(matches!(
1330 service
1331 .initialize(
1332 &first,
1333 MintInitializationMode::New,
1334 None,
1335 false,
1336 Path::new("."),
1337 BdkWalletPolicy::RequireExisting,
1338 )
1339 .await,
1340 Err(ConfigurationServiceError::Store(
1341 ConfigStoreError::AlreadyInitialized
1342 ))
1343 ));
1344
1345 let outcome = service
1346 .apply(
1347 &second,
1348 true,
1349 Path::new("."),
1350 BdkWalletPolicy::RequireExisting,
1351 )
1352 .await
1353 .expect("validate replacement");
1354 assert!(!outcome.restart_required);
1355 assert_eq!(service.document().await.expect("stored document"), first);
1356
1357 let running_snapshot = service.startup().await.expect("running snapshot");
1358 assert!(service
1359 .mark_applied(running_snapshot.revision)
1360 .await
1361 .expect("mark first document applied"));
1362 let outcome = service
1363 .apply(
1364 &second,
1365 false,
1366 Path::new("."),
1367 BdkWalletPolicy::RequireExisting,
1368 )
1369 .await
1370 .expect("replace configuration");
1371 assert!(outcome.restart_required);
1372 assert_eq!(service.document().await.expect("stored document"), second);
1373 assert_eq!(running_snapshot.resolved.settings.mint_info.name, "first");
1374 let next_startup = service.startup().await.expect("startup document");
1375 assert_eq!(next_startup.resolved.settings.mint_info.name, "second");
1376 assert_eq!(next_startup.state, DocumentState::Pending);
1377
1378 let rollback = service.rollback().await.expect("rollback pending document");
1379 assert!(rollback.restart_required);
1380 assert_eq!(service.document().await.expect("restored document"), first);
1381 assert!(service
1382 .has_pending_configuration()
1383 .await
1384 .expect("restored document requires activation"));
1385
1386 let _ = std::fs::remove_file(secret_path);
1387 }
1388
1389 #[cfg(feature = "fakewallet")]
1390 #[test]
1391 fn startup_document_ignores_general_operational_environment_overrides() {
1392 let _env_lock = crate::test_utils::env_lock();
1393 let secret_path = crate::test_utils::unique_temp_path("startup_config_secret");
1394 std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1395 std::env::set_var(crate::env_vars::ENV_LISTEN_PORT, "6553");
1396 let document = format!(
1397 r#"
1398[info]
1399listen_port = 8091
1400mnemonic = "file:{}"
1401
1402[payment_backend]
1403backend = "fakewallet"
1404
1405[fake_wallet]
1406
1407[database]
1408engine = "sqlite"
1409"#,
1410 secret_path.display()
1411 );
1412 let resolved =
1413 ConfigurationService::validate_document(&document).expect("validate startup document");
1414 assert_eq!(resolved.settings.info.listen_port, 8091);
1415
1416 std::env::remove_var(crate::env_vars::ENV_LISTEN_PORT);
1417 let _ = std::fs::remove_file(secret_path);
1418 }
1419
1420 #[test]
1421 fn configuration_without_payment_backend_is_rejected() {
1422 let secret_path = crate::test_utils::unique_temp_path("no_payment_backend_secret");
1423 std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1424 let document = format!(
1425 r#"
1426[info]
1427mnemonic = "file:{}"
1428
1429[payment_backend]
1430backend = "none"
1431
1432[database]
1433engine = "sqlite"
1434"#,
1435 secret_path.display()
1436 );
1437 let error = ConfigurationService::validate_document(&document)
1438 .expect_err("configuration without a payment backend should fail");
1439 assert!(
1440 matches!(
1441 &error,
1442 ConfigurationServiceError::Validation(message)
1443 if message.contains("At least one payment backend")
1444 ),
1445 "unexpected error: {error}"
1446 );
1447
1448 let _ = std::fs::remove_file(secret_path);
1449 }
1450
1451 #[cfg(feature = "fakewallet")]
1452 #[test]
1453 fn selected_backend_without_its_configuration_section_is_rejected() {
1454 let secret_path = crate::test_utils::unique_temp_path("missing_backend_section_secret");
1455 std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1456 let document = format!(
1457 r#"
1458[info]
1459mnemonic = "file:{}"
1460
1461[payment_backend]
1462backend = "fakewallet"
1463
1464[database]
1465engine = "sqlite"
1466"#,
1467 secret_path.display()
1468 );
1469 let error = ConfigurationService::validate_document(&document)
1470 .expect_err("selected backend without its config section should fail");
1471 assert!(
1472 matches!(
1473 &error,
1474 ConfigurationServiceError::Validation(message)
1475 if message.contains(
1476 "Fake wallet backend selected but [fake_wallet] config section is missing"
1477 )
1478 ),
1479 "unexpected error: {error}"
1480 );
1481
1482 let _ = std::fs::remove_file(secret_path);
1483 }
1484
1485 #[cfg(feature = "bdk")]
1486 #[test]
1487 fn opaque_environment_and_file_secrets_preserve_whitespace() {
1488 let _env_lock = crate::test_utils::env_lock();
1489 const PASSWORD_ENV: &str = "CDK_MINTD_TEST_WHITESPACE_BDK_PASSWORD";
1490 let mnemonic_path = crate::test_utils::unique_temp_path("whitespace_secret_mnemonic");
1491 let url_path = crate::test_utils::unique_temp_path("whitespace_secret_postgres_url");
1492 std::fs::write(&mnemonic_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1493 std::fs::write(&url_path, "\tpostgresql://user:pass@localhost/cdk\n")
1494 .expect("write postgres url secret");
1495 std::env::set_var(PASSWORD_ENV, " password-secret ");
1496 let document = format!(
1497 r#"
1498[info]
1499mnemonic = "file:{}"
1500
1501[payment_backend]
1502backend = "none"
1503
1504[onchain]
1505onchain_backend = "bdk"
1506
1507[bdk]
1508network = "regtest"
1509bitcoind_rpc_password = "env:{PASSWORD_ENV}"
1510
1511[database]
1512engine = "postgres"
1513
1514[database.postgres]
1515url = "file:{}"
1516"#,
1517 mnemonic_path.display(),
1518 url_path.display()
1519 );
1520
1521 let resolved = ConfigurationService::validate_document(&document)
1522 .expect("opaque secrets containing whitespace should validate");
1523 let bdk = resolved
1524 .settings
1525 .bdk
1526 .expect("bdk configuration should be present");
1527 assert_eq!(
1528 bdk.bitcoind_rpc_password.as_deref(),
1529 Some(" password-secret ")
1530 );
1531 let postgres = resolved
1532 .settings
1533 .database
1534 .postgres
1535 .expect("postgres configuration should be present");
1536 assert_eq!(postgres.url, "\tpostgresql://user:pass@localhost/cdk\n");
1537
1538 std::env::remove_var(PASSWORD_ENV);
1539 let _ = std::fs::remove_file(mnemonic_path);
1540 let _ = std::fs::remove_file(url_path);
1541 }
1542
1543 #[cfg(feature = "fakewallet")]
1544 #[test]
1545 fn mnemonic_secret_trims_surrounding_whitespace() {
1546 let mnemonic_path = crate::test_utils::unique_temp_path("trimmed_config_mnemonic");
1547 std::fs::write(&mnemonic_path, format!(" {TEST_MNEMONIC_ONE}\n"))
1548 .expect("write signing secret");
1549
1550 let resolved = ConfigurationService::validate_document(&document(
1551 &format!("file:{}", mnemonic_path.display()),
1552 "trimmed",
1553 ))
1554 .expect("mnemonic surrounding whitespace should be normalized");
1555 assert_eq!(
1556 resolved.settings.info.mnemonic.as_deref(),
1557 Some(TEST_MNEMONIC_ONE)
1558 );
1559
1560 let _ = std::fs::remove_file(mnemonic_path);
1561 }
1562
1563 #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1564 #[tokio::test]
1565 async fn apply_rejects_signer_and_primary_database_changes() {
1566 let signer_path = crate::test_utils::unique_temp_path("signer_config_secret");
1567 let postgres_path = crate::test_utils::unique_temp_path("postgres_config_secret");
1568 std::fs::write(&signer_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1569 std::fs::write(&postgres_path, "postgresql://localhost/cdk-test")
1570 .expect("write postgres secret");
1571 let service = service().await;
1572 let first = document(&format!("file:{}", signer_path.display()), "first");
1573 service
1574 .initialize(
1575 &first,
1576 MintInitializationMode::New,
1577 None,
1578 false,
1579 Path::new("."),
1580 BdkWalletPolicy::RequireExisting,
1581 )
1582 .await
1583 .expect("initialize configuration");
1584
1585 std::fs::write(&signer_path, TEST_MNEMONIC_TWO).expect("replace signing secret");
1586 assert!(matches!(
1587 service
1588 .apply(
1589 &first,
1590 false,
1591 Path::new("."),
1592 BdkWalletPolicy::RequireExisting,
1593 )
1594 .await,
1595 Err(ConfigurationServiceError::SigningIdentityChange)
1596 ));
1597
1598 std::fs::write(&signer_path, TEST_MNEMONIC_ONE).expect("restore signing secret");
1599 let postgres = format!(
1600 r#"
1601[info]
1602mnemonic = "file:{}"
1603
1604[payment_backend]
1605backend = "fakewallet"
1606
1607[fake_wallet]
1608
1609[database]
1610engine = "postgres"
1611
1612[database.postgres]
1613url = "file:{}"
1614"#,
1615 signer_path.display(),
1616 postgres_path.display()
1617 );
1618 assert!(matches!(
1619 service
1620 .apply(
1621 &postgres,
1622 false,
1623 Path::new("."),
1624 BdkWalletPolicy::RequireExisting,
1625 )
1626 .await,
1627 Err(ConfigurationServiceError::PrimaryDatabaseChange)
1628 ));
1629
1630 let _ = std::fs::remove_file(signer_path);
1631 let _ = std::fs::remove_file(postgres_path);
1632 }
1633
1634 #[cfg(feature = "fakewallet")]
1635 #[test]
1636 fn seed_secret_and_inactive_sections_are_resolved_and_pruned() {
1637 let _env_lock = crate::test_utils::env_lock();
1638 const SEED_ENV: &str = "CDK_MINTD_TEST_CONFIG_SEED_SECRET";
1639 let seed = "a".repeat(32);
1640 std::env::set_var(SEED_ENV, &seed);
1641
1642 let document = format!(
1643 r#"
1644[info]
1645seed = "env:{SEED_ENV}"
1646
1647[mint_info]
1648name = "pruned"
1649
1650[payment_backend]
1651backend = "fakewallet"
1652
1653[fake_wallet]
1654
1655[database]
1656engine = "sqlite"
1657
1658[database.postgres]
1659url = "env:SHOULD_BE_PRUNED"
1660
1661[auth]
1662auth_enabled = false
1663openid_discovery = "https://example.com/.well-known/openid-configuration"
1664openid_client_id = "client"
1665
1666[auth_database]
1667[auth_database.postgres]
1668url = "env:SHOULD_BE_PRUNED"
1669
1670[signatory]
1671enabled = false
1672address = "127.0.0.1"
1673port = 15060
1674allow_insecure = true
1675"#
1676 );
1677
1678 let resolved = ConfigurationService::validate_document(&document)
1679 .expect("validate seed-backed document");
1680 assert_eq!(resolved.settings.info.seed.as_deref(), Some(seed.as_str()));
1681 assert!(resolved.settings.database.postgres.is_none());
1682 assert!(resolved.settings.auth.is_none());
1683 assert!(resolved.settings.auth_database.is_none());
1684 assert!(resolved.settings.signatory.is_none());
1685 assert!(format!("{resolved:?}").contains("redacted"));
1686
1687 let identity = discover_signing_identity(&resolved.settings).expect("seed identity");
1688 assert!(!identity.fingerprint.is_empty());
1689
1690 std::env::remove_var(SEED_ENV);
1691 }
1692
1693 #[cfg(feature = "fakewallet")]
1694 #[test]
1695 fn file_secret_errors_empty_env_name_and_missing_file_are_reported() {
1696 let missing = crate::test_utils::unique_temp_path("missing_config_secret");
1697 assert!(matches!(
1698 ConfigurationService::validate_document(&document("env:", "empty-name")),
1699 Err(ConfigurationServiceError::EmptySecret {
1700 field: "info.mnemonic"
1701 })
1702 ));
1703 assert!(matches!(
1704 ConfigurationService::validate_document(&document(
1705 &format!("file:{}", missing.display()),
1706 "missing-file"
1707 )),
1708 Err(ConfigurationServiceError::FileSecret {
1709 field: "info.mnemonic",
1710 ..
1711 })
1712 ));
1713 }
1714
1715 #[cfg(feature = "fakewallet")]
1716 #[tokio::test]
1717 async fn authored_mint_pubkey_must_match_signer() {
1718 let secret_one = crate::test_utils::unique_temp_path("pubkey_config_secret_one");
1719 let secret_two = crate::test_utils::unique_temp_path("pubkey_config_secret_two");
1720 std::fs::write(&secret_one, TEST_MNEMONIC_ONE).expect("write signing secret");
1721 std::fs::write(&secret_two, TEST_MNEMONIC_TWO).expect("write other signing secret");
1722
1723 let identity = discover_signing_identity(
1724 &ConfigurationService::validate_document(&document(
1725 &format!("file:{}", secret_one.display()),
1726 "identity",
1727 ))
1728 .expect("resolve identity document")
1729 .settings,
1730 )
1731 .expect("discover identity");
1732 let other = discover_signing_identity(
1733 &ConfigurationService::validate_document(&document(
1734 &format!("file:{}", secret_two.display()),
1735 "other",
1736 ))
1737 .expect("resolve other document")
1738 .settings,
1739 )
1740 .expect("discover other");
1741
1742 let mismatch = format!(
1743 r#"
1744[info]
1745mnemonic = "file:{}"
1746
1747[mint_info]
1748name = "mismatch"
1749pubkey = "{}"
1750
1751[payment_backend]
1752backend = "fakewallet"
1753
1754[fake_wallet]
1755
1756[database]
1757engine = "sqlite"
1758"#,
1759 secret_one.display(),
1760 other.pubkey
1761 );
1762 assert!(matches!(
1763 ConfigurationService::validate_import(&mismatch).await,
1764 Err(ConfigurationServiceError::SigningIdentityChange)
1765 ));
1766
1767 let matching = format!(
1768 r#"
1769[info]
1770mnemonic = "file:{}"
1771
1772[mint_info]
1773name = "match"
1774pubkey = "{}"
1775
1776[payment_backend]
1777backend = "fakewallet"
1778
1779[fake_wallet]
1780
1781[database]
1782engine = "sqlite"
1783"#,
1784 secret_one.display(),
1785 identity.pubkey
1786 );
1787 ConfigurationService::validate_import(&matching)
1788 .await
1789 .expect("matching pubkey");
1790 let _ = std::fs::remove_file(secret_one);
1791 let _ = std::fs::remove_file(secret_two);
1792 }
1793
1794 #[test]
1795 fn remote_signatory_requires_async_discovery() {
1796 let settings = Settings {
1797 signatory: Some(crate::config::Signatory {
1798 enabled: true,
1799 address: "127.0.0.1".to_owned(),
1800 port: 15060,
1801 tls_dir: None,
1802 allow_insecure: true,
1803 }),
1804 ..Default::default()
1805 };
1806 assert!(matches!(
1807 discover_signing_identity(&settings),
1808 Err(ConfigurationServiceError::SigningIdentity(message))
1809 if message.contains("asynchronous")
1810 ));
1811 }
1812
1813 #[test]
1814 fn same_primary_database_compares_engine_and_postgres_fields() {
1815 let sqlite = Database::default();
1816 let other_sqlite = Database {
1817 engine: DatabaseEngine::Sqlite,
1818 postgres: Some(crate::config::PostgresConfig {
1819 url: "postgresql://ignored".to_owned(),
1820 ..Default::default()
1821 }),
1822 };
1823 assert!(same_primary_database(&sqlite, &other_sqlite));
1824
1825 let left = Database {
1826 engine: DatabaseEngine::Postgres,
1827 postgres: Some(crate::config::PostgresConfig {
1828 url: "postgresql://a".to_owned(),
1829 tls_mode: Some("disable".to_owned()),
1830 max_connections: Some(5),
1831 connection_timeout_seconds: Some(3),
1832 }),
1833 };
1834 let right = Database {
1835 engine: DatabaseEngine::Postgres,
1836 postgres: Some(crate::config::PostgresConfig {
1837 url: "postgresql://a".to_owned(),
1838 tls_mode: Some("disable".to_owned()),
1839 max_connections: Some(5),
1840 connection_timeout_seconds: Some(3),
1841 }),
1842 };
1843 assert!(same_primary_database(&left, &right));
1844 let mut different = right.clone();
1845 different.postgres.as_mut().expect("postgres").url = "postgresql://b".to_owned();
1846 assert!(!same_primary_database(&left, &different));
1847 assert!(!same_primary_database(
1848 &left,
1849 &Database {
1850 engine: DatabaseEngine::Postgres,
1851 postgres: None,
1852 }
1853 ));
1854 assert!(!same_primary_database(&sqlite, &left));
1855 }
1856
1857 #[cfg(feature = "postgres")]
1858 #[test]
1859 fn same_primary_database_compares_effective_tls_policies() {
1860 for (url_mode, stored_mode, bootstrap_mode, expected) in [
1861 ("", Some("disable"), None, true),
1862 ("?sslmode=require", Some("require"), None, true),
1863 ("?sslmode=require", Some("disable"), None, false),
1864 ("", Some("REQUIRE"), Some("require"), true),
1865 ("", Some("require"), Some("verify-ca"), false),
1866 ("", Some("verify-ca"), Some("verify-full"), false),
1867 ("", Some("invalid"), Some("invalid"), false),
1868 ("?sslmode=invalid", Some("disable"), Some("disable"), false),
1869 ] {
1870 let database = |mode: Option<&str>| Database {
1871 engine: DatabaseEngine::Postgres,
1872 postgres: Some(crate::config::PostgresConfig {
1873 url: format!("postgresql://localhost/cdk{url_mode}"),
1874 tls_mode: mode.map(str::to_owned),
1875 ..Default::default()
1876 }),
1877 };
1878 assert_eq!(
1879 same_primary_database(&database(stored_mode), &database(bootstrap_mode)),
1880 expected,
1881 "URL {url_mode}, stored {stored_mode:?}, bootstrap {bootstrap_mode:?}"
1882 );
1883 }
1884 }
1885
1886 #[cfg(all(feature = "postgres", feature = "sqlite", feature = "fakewallet"))]
1887 #[tokio::test]
1888 async fn startup_accepts_stored_explicit_disable_with_unset_bootstrap_tls() {
1889 let mnemonic_path = crate::test_utils::unique_temp_path("tls_upgrade_mnemonic");
1890 let url_path = crate::test_utils::unique_temp_path("tls_upgrade_url");
1891 std::fs::write(&mnemonic_path, TEST_MNEMONIC_ONE).expect("write mnemonic");
1892 std::fs::write(&url_path, "postgresql://localhost/cdk").expect("write URL");
1893 let document = format!(
1894 r#"
1895[info]
1896mnemonic = "file:{}"
1897[payment_backend]
1898backend = "fakewallet"
1899[fake_wallet]
1900[database]
1901engine = "postgres"
1902[database.postgres]
1903url = "file:{}"
1904tls_mode = "disable"
1905"#,
1906 mnemonic_path.display(),
1907 url_path.display()
1908 );
1909 let resolved = ConfigurationService::validate_document(&document).expect("legacy document");
1910 let identity = discover_signing_identity(&resolved.settings).expect("identity");
1911 let mut bootstrap = resolved.settings.database;
1912 bootstrap.postgres.as_mut().expect("postgres").tls_mode = None;
1913 let database = Arc::new(memory::empty().await.expect("in-memory config store"));
1914 let repository = ConfigRepository::new(database);
1915 repository
1916 .initialize(ConfigEnvelope::new(document, identity.fingerprint))
1917 .await
1918 .expect("store legacy configuration");
1919 let service = ConfigurationService::new(repository, bootstrap);
1920 service.startup().await.expect("compatible startup");
1921 std::fs::remove_file(mnemonic_path).expect("remove mnemonic");
1922 std::fs::remove_file(url_path).expect("remove URL");
1923 }
1924
1925 #[test]
1926 fn initialization_state_allows_remote_keysets_to_remain_external() {
1927 let pubkey = cdk::nuts::PublicKey::from_hex(
1928 "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619",
1929 )
1930 .expect("static pubkey");
1931
1932 require_initialization_state(MintInitializationMode::Existing, Some(pubkey), false, true)
1933 .expect("remote signatory keysets remain in the signatory database");
1934 assert!(matches!(
1935 require_initialization_state(
1936 MintInitializationMode::Existing,
1937 Some(pubkey),
1938 false,
1939 false,
1940 ),
1941 Err(ConfigurationServiceError::ExistingMintMissingKeysets)
1942 ));
1943 assert!(matches!(
1944 require_initialization_state(MintInitializationMode::Existing, None, false, true),
1945 Err(ConfigurationServiceError::ExistingMintMissingIdentity)
1946 ));
1947 assert!(matches!(
1948 require_initialization_state(MintInitializationMode::New, Some(pubkey), false, false,),
1949 Err(ConfigurationServiceError::NewMintHasExistingState)
1950 ));
1951 }
1952
1953 #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1954 #[tokio::test]
1955 async fn initialize_rejects_existing_mint_pubkey_mismatch_and_mark_applied_tracks_document() {
1956 let secret_path = crate::test_utils::unique_temp_path("init_pubkey_config_secret");
1957 std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1958 let service = service().await;
1959 let first = document(&format!("file:{}", secret_path.display()), "first");
1960 let identity = discover_signing_identity(
1961 &ConfigurationService::validate_document(&first)
1962 .expect("resolve")
1963 .settings,
1964 )
1965 .expect("identity");
1966
1967 std::fs::write(&secret_path, TEST_MNEMONIC_TWO).expect("swap mnemonic");
1968 let other = discover_signing_identity(
1969 &ConfigurationService::validate_document(&document(
1970 &format!("file:{}", secret_path.display()),
1971 "other",
1972 ))
1973 .expect("resolve other")
1974 .settings,
1975 )
1976 .expect("other identity");
1977 std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("restore mnemonic");
1978
1979 assert!(matches!(
1980 service
1981 .initialize(
1982 &first,
1983 MintInitializationMode::Existing,
1984 Some(other.pubkey),
1985 true,
1986 Path::new("."),
1987 BdkWalletPolicy::RequireExisting,
1988 )
1989 .await,
1990 Err(ConfigurationServiceError::SigningIdentityChange)
1991 ));
1992
1993 service
1994 .initialize(
1995 &first,
1996 MintInitializationMode::Existing,
1997 Some(identity.pubkey),
1998 true,
1999 Path::new("."),
2000 BdkWalletPolicy::RequireExisting,
2001 )
2002 .await
2003 .expect("initialize with matching mint pubkey");
2004 assert!(service
2005 .has_pending_configuration()
2006 .await
2007 .expect("initialized document is pending"));
2008 let initial_revision = service.startup().await.expect("initial startup").revision;
2009 assert!(service
2010 .mark_applied(initial_revision)
2011 .await
2012 .expect("mark applied"));
2013 assert!(!service
2014 .has_pending_configuration()
2015 .await
2016 .expect("applied document is active"));
2017 let startup = service.startup().await.expect("startup");
2018 assert_eq!(startup.state, DocumentState::Applied);
2019 assert_eq!(startup.signing_identity.pubkey, identity.pubkey);
2020 assert!(startup.remote_signatory.is_none());
2021
2022 let second = document(&format!("file:{}", secret_path.display()), "second");
2023 service
2024 .apply(
2025 &second,
2026 false,
2027 Path::new("."),
2028 BdkWalletPolicy::RequireExisting,
2029 )
2030 .await
2031 .expect("replace document");
2032 assert!(service
2033 .has_pending_configuration()
2034 .await
2035 .expect("replacement requires restart"));
2036 assert!(!service
2037 .mark_applied(initial_revision)
2038 .await
2039 .expect("stale document remains unapplied"));
2040
2041 let _ = std::fs::remove_file(secret_path);
2042 }
2043}