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