Skip to main content

cdk_mintd/
config_service.rs

1//! Validation and lifecycle rules for database-backed mintd configuration.
2
3use 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/// Cryptographic identity of the configured signer.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) struct SigningIdentity {
27    pub(crate) pubkey: cdk::nuts::PublicKey,
28    pub(crate) fingerprint: String,
29}
30
31/// A validated document and its resolved runtime settings.
32#[derive(Clone)]
33pub struct ResolvedConfiguration {
34    /// Original import document containing secret references.
35    pub document: String,
36    /// Runtime settings with secret references resolved.
37    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/// Configuration selected for daemon startup.
50#[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/// Result of a configuration apply operation.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct ApplyOutcome {
81    /// Applying a new document requires a daemon restart.
82    pub restart_required: bool,
83}
84
85/// Result of a configuration rollback operation.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct RollbackOutcome {
88    /// The restored document requires a daemon restart before it is active.
89    pub restart_required: bool,
90}
91
92/// Database-backed configuration failures.
93#[derive(Debug, Error)]
94pub enum ConfigurationServiceError {
95    /// The TOML document could not be parsed.
96    #[error("invalid mintd configuration document: {0}")]
97    Parse(#[from] config::ConfigError),
98
99    /// A secret was embedded directly instead of referenced.
100    #[error("{field} must use an `env:VARIABLE` or `file:/absolute/path` secret reference")]
101    LiteralSecret {
102        /// Configuration field containing the literal.
103        field: &'static str,
104    },
105
106    /// An environment secret could not be resolved.
107    #[error("could not resolve {field} from environment variable {name}")]
108    EnvironmentSecret {
109        /// Configuration field being resolved.
110        field: &'static str,
111        /// Referenced variable.
112        name: String,
113    },
114
115    /// A file secret could not be resolved.
116    #[error("could not resolve {field} from secret file {}: {source}", path.display())]
117    FileSecret {
118        /// Configuration field being resolved.
119        field: &'static str,
120        /// Referenced file.
121        path: std::path::PathBuf,
122        /// File access failure.
123        #[source]
124        source: std::io::Error,
125    },
126
127    /// A secret reference was empty or resolved to an empty value.
128    #[error("secret reference for {field} resolved to an empty value")]
129    EmptySecret {
130        /// Configuration field being resolved.
131        field: &'static str,
132    },
133
134    /// Runtime validation rejected the resolved settings.
135    #[error("invalid mintd configuration: {0}")]
136    Validation(String),
137
138    /// The document points at a different primary database.
139    #[error("primary database settings do not match the bootstrap database settings")]
140    PrimaryDatabaseChange,
141
142    /// The configured signer could not be identified.
143    #[error("could not determine configured mint signing identity: {0}")]
144    SigningIdentity(String),
145
146    /// The configured signer differs from the database identity.
147    #[error(
148        "configured signing identity does not match this mint database; signer migration is not supported by config apply"
149    )]
150    SigningIdentityChange,
151
152    /// New-mint initialization targeted a database containing mint state.
153    #[error(
154        "refusing new-mint initialization because the selected database contains existing mint identity or keyset state"
155    )]
156    NewMintHasExistingState,
157
158    /// A new mint's raw seed was shorter than the required minimum.
159    #[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        /// Resolved seed length in bytes.
164        actual: usize,
165        /// Minimum seed length for a new mint.
166        minimum: usize,
167    },
168
169    /// Existing-mint initialization targeted a database without a mint identity.
170    #[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    /// An embedded-signatory mint database had no historical keysets.
176    #[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    /// BDK wallet state did not satisfy an existing-wallet preflight.
182    #[cfg(feature = "bdk")]
183    #[error("BDK wallet preflight failed: {0}")]
184    BdkWalletPreflight(#[source] cdk_bdk::Error),
185
186    /// Persistent configuration storage failed.
187    #[error(transparent)]
188    Store(#[from] ConfigStoreError),
189}
190
191/// Service for the single authoritative configuration record.
192#[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    /// Parses, resolves, and validates an import document.
207    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    /// Validates an import document and verifies its configured signer.
223    pub(crate) async fn validate_import(
224        document: &str,
225    ) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
226        Ok(Self::validated_import(document).await?.0)
227    }
228
229    /// Initializes an empty configuration repository.
230    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    /// Validates and optionally replaces the authoritative document.
290    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    /// Loads and validates the document selected for startup.
320    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    /// Returns the stored import document without resolved secrets.
343    pub(crate) async fn document(&self) -> Result<String, ConfigurationServiceError> {
344        Ok(self.repository.active().await?.toml)
345    }
346
347    /// Reports whether the stored configuration still requires a restart.
348    #[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    /// Marks the current startup document applied if it has not been replaced.
359    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    /// Stages the last configuration known to have reached applied state.
367    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
488/// Resolves the signer, including a configured remote signatory.
489pub(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), // typos: ignore
873                ("", "?sslmode=requre", false),       // typos: ignore
874            ] {
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}