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";
22
23/// Cryptographic identity of the configured signer.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub(crate) struct SigningIdentity {
26    pub(crate) pubkey: cdk::nuts::PublicKey,
27    pub(crate) fingerprint: String,
28}
29
30/// A validated document and its resolved runtime settings.
31#[derive(Clone)]
32pub struct ResolvedConfiguration {
33    /// Original import document containing secret references.
34    pub document: String,
35    /// Runtime settings with secret references resolved.
36    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/// Configuration selected for daemon startup.
49#[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/// Result of a configuration apply operation.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct ApplyOutcome {
80    /// Applying a new document requires a daemon restart.
81    pub restart_required: bool,
82}
83
84/// Result of a configuration rollback operation.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct RollbackOutcome {
87    /// The restored document requires a daemon restart before it is active.
88    pub restart_required: bool,
89}
90
91/// Database-backed configuration failures.
92#[derive(Debug, Error)]
93pub enum ConfigurationServiceError {
94    /// The TOML document could not be parsed.
95    #[error("invalid mintd configuration document: {0}")]
96    Parse(#[from] config::ConfigError),
97
98    /// A secret was embedded directly instead of referenced.
99    #[error("{field} must use an `env:VARIABLE` or `file:/absolute/path` secret reference")]
100    LiteralSecret {
101        /// Configuration field containing the literal.
102        field: &'static str,
103    },
104
105    /// An environment secret could not be resolved.
106    #[error("could not resolve {field} from environment variable {name}")]
107    EnvironmentSecret {
108        /// Configuration field being resolved.
109        field: &'static str,
110        /// Referenced variable.
111        name: String,
112    },
113
114    /// A file secret could not be resolved.
115    #[error("could not resolve {field} from secret file {}: {source}", path.display())]
116    FileSecret {
117        /// Configuration field being resolved.
118        field: &'static str,
119        /// Referenced file.
120        path: std::path::PathBuf,
121        /// File access failure.
122        #[source]
123        source: std::io::Error,
124    },
125
126    /// A secret reference was empty or resolved to an empty value.
127    #[error("secret reference for {field} resolved to an empty value")]
128    EmptySecret {
129        /// Configuration field being resolved.
130        field: &'static str,
131    },
132
133    /// Runtime validation rejected the resolved settings.
134    #[error("invalid mintd configuration: {0}")]
135    Validation(String),
136
137    /// The document points at a different primary database.
138    #[error("primary database settings do not match the bootstrap database settings")]
139    PrimaryDatabaseChange,
140
141    /// The configured signer could not be identified.
142    #[error("could not determine configured mint signing identity: {0}")]
143    SigningIdentity(String),
144
145    /// The configured signer differs from the database identity.
146    #[error(
147        "configured signing identity does not match this mint database; signer migration is not supported by config apply"
148    )]
149    SigningIdentityChange,
150
151    /// New-mint initialization targeted a database containing mint state.
152    #[error(
153        "refusing new-mint initialization because the selected database contains existing mint identity or keyset state"
154    )]
155    NewMintHasExistingState,
156
157    /// Existing-mint initialization targeted a database without a mint identity.
158    #[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    /// An embedded-signatory mint database had no historical keysets.
164    #[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    /// BDK wallet state did not satisfy an existing-wallet preflight.
170    #[cfg(feature = "bdk")]
171    #[error("BDK wallet preflight failed: {0}")]
172    BdkWalletPreflight(#[source] cdk_bdk::Error),
173
174    /// Persistent configuration storage failed.
175    #[error(transparent)]
176    Store(#[from] ConfigStoreError),
177}
178
179/// Service for the single authoritative configuration record.
180#[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    /// Parses, resolves, and validates an import document.
195    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    /// Validates an import document and verifies its configured signer.
211    pub(crate) async fn validate_import(
212        document: &str,
213    ) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
214        Ok(Self::validated_import(document).await?.0)
215    }
216
217    /// Initializes an empty configuration repository.
218    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    /// Validates and optionally replaces the authoritative document.
259    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    /// Loads and validates the document selected for startup.
289    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    /// Returns the stored import document without resolved secrets.
312    pub(crate) async fn document(&self) -> Result<String, ConfigurationServiceError> {
313        Ok(self.repository.active().await?.toml)
314    }
315
316    /// Reports whether the stored configuration still requires a restart.
317    #[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    /// Marks the current startup document applied if it has not been replaced.
328    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    /// Stages the last configuration known to have reached applied state.
336    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
457/// Resolves the signer, including a configured remote signatory.
458pub(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}