diff --git a/.env.example b/.env.example index 3fcc59610..8393fed69 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,24 @@ FORCE_ISOCODE= # Breez SDK API Key (for Lightning Network features) # Get your API key at: https://sdk-doc-liquid.breez.technology/guide/getting_started.html#api-key -BREEZ_API_KEY= \ No newline at end of file +BREEZ_API_KEY= + +# --- Feature Flags --- +# Enable the passkey flow for Cube creation (WebAuthn + PRF extension). +# Requires macOS code signing with associated-domains entitlement + an AASA +# file hosted at coincube.io/.well-known/apple-app-site-association. +# Default: off. Set to 1 / true / yes to enable the "Use Passkey" toggle +# in the Create Cube form. +COINCUBE_ENABLE_PASSKEY=0 + +# --- Passkey Ceremony Configuration --- +# URL of the hosted passkey ceremony page (WebAuthn + PRF). The non-macOS +# webview fallback navigates here; macOS uses AuthenticationServices natively +# but still needs RP_ID below to match the ceremony domain. +# Default (unset): http://localhost:8080/passkey +COINCUBE_PASSKEY_CEREMONY_URL= + +# Relying Party ID for WebAuthn — must match the ceremony page's domain +# (no scheme, no path). For production set to the real domain, e.g. +# coincube.io. Default (unset): localhost +COINCUBE_PASSKEY_RP_ID= diff --git a/Cargo.lock b/Cargo.lock index beb72f505..fa9f0d3f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1620,6 +1620,9 @@ dependencies = [ "log", "mostro-core", "nostr-sdk 0.43.0", + "objc2 0.6.4", + "objc2-authentication-services", + "objc2-foundation 0.3.2", "open", "rand 0.8.5", "reqwest 0.12.18", @@ -6047,6 +6050,21 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "objc2-authentication-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6d6f7dab884a28adaec1012eb3889257a49cc145724e35f93ece2d209f8b25" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" @@ -6289,6 +6307,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-symbols" version = "0.2.2" diff --git a/coincube-core/Cargo.toml b/coincube-core/Cargo.toml index f86f50eb9..89d9d380e 100644 --- a/coincube-core/Cargo.toml +++ b/coincube-core/Cargo.toml @@ -23,7 +23,7 @@ log = "0.4" # Used for generating mnemonics getrandom = "0.3.1" -# Used for the hot signer +# Used for the master signer bip39 = "2.0" argon2 = "0.5.3" aes-gcm = "0.10.3" diff --git a/coincube-core/src/border_wallet/mod.rs b/coincube-core/src/border_wallet/mod.rs index 41d5758b5..56c29b5b2 100644 --- a/coincube-core/src/border_wallet/mod.rs +++ b/coincube-core/src/border_wallet/mod.rs @@ -38,7 +38,7 @@ use miniscript::bitcoin::{ }; use zeroize::Zeroizing; -use crate::signer::HotSigner; +use crate::signer::MasterSigner; /// A secret-bearing grid recovery phrase. /// @@ -77,6 +77,38 @@ impl GridRecoveryPhrase { }) } + /// Derive a deterministic GridRecoveryPhrase from a master signer using BIP-85. + /// + /// Uses derivation path `m/83696968'/39'/0'/12'/0'` (BIP-85: purpose / BIP-39 app + /// / English / 12 words / index 0). The child private key is processed through + /// HMAC-SHA-512 with the BIP-85 domain tag to produce 16 bytes of entropy for a + /// 12-word mnemonic. + pub fn from_master_signer( + signer: &MasterSigner, + secp: &secp256k1::Secp256k1, + ) -> Result { + use miniscript::bitcoin::hashes::{sha512, Hash, HashEngine, Hmac, HmacEngine}; + + let path: bip32::DerivationPath = "m/83696968'/39'/0'/12'/0'" + .parse() + .expect("hardcoded path is valid"); + let child_xpriv = signer.xpriv_at(&path, secp); + + // BIP-85: HMAC-SHA-512 with domain tag "bip-entropy-from-k" + let mut engine = HmacEngine::::new(b"bip-entropy-from-k"); + engine.input(&child_xpriv.to_priv().to_bytes()); + let hmac = Hmac::::from_engine(engine); + + // Take first 16 bytes for 128 bits of entropy → 12-word mnemonic + let entropy = &hmac.to_byte_array()[..16]; + let mnemonic = bip39::Mnemonic::from_entropy(entropy) + .map_err(|_| BorderWalletError::InvalidRecoveryPhrase)?; + + Ok(Self { + phrase: Zeroizing::new(mnemonic.to_string()), + }) + } + /// Access the phrase as a string slice. Use only for grid generation and display. pub fn as_str(&self) -> &str { &self.phrase @@ -153,7 +185,7 @@ pub fn derive_enrollment( /// Sign a PSBT using a transiently reconstructed Border Wallet key. /// /// This function: -/// 1. Creates a transient `HotSigner` from the reconstructed mnemonic +/// 1. Creates a transient `MasterSigner` from the reconstructed mnemonic /// 2. Verifies the fingerprint matches the expected enrollment fingerprint /// 3. Signs the PSBT /// 4. All secret material is dropped when this function returns @@ -167,7 +199,7 @@ pub fn sign_psbt_with_border_wallet( ) -> Result<(Fingerprint, Psbt), BorderWalletError> { let secp = secp256k1::Secp256k1::new(); - let signer = HotSigner::from_mnemonic(network, mnemonic) + let signer = MasterSigner::from_mnemonic(network, mnemonic) .map_err(|e| BorderWalletError::KeyDerivation(e.to_string()))?; let actual_fingerprint = signer.fingerprint(&secp); @@ -185,11 +217,23 @@ pub fn sign_psbt_with_border_wallet( Ok((actual_fingerprint, signed_psbt)) } -/// The standard derivation path for Border Wallet signers. +/// The default derivation path for Border Wallet signers. /// -/// Uses BIP-48 multisig path with script type 2 (Taproot): +/// Uses the BIP-48 native segwit multisig path: /// - Mainnet: m/48'/0'/0'/2' /// - Testnet/Signet: m/48'/1'/0'/2' +/// +/// Per BIP-48, script type `2'` is **P2WSH** (native segwit multisig). +/// COINCUBE currently re-uses this same path for both P2WSH and Taproot +/// multisig vaults, because BIP-48 does not define a Taproot multisig +/// script type and there is no consensus standard for one. A proposed +/// extension using `3'` for Taproot multisig +/// ([bitcoin/bips#1473](https://github.com/bitcoin/bips/pull/1473)) +/// was closed without merging in May 2024. +/// +/// If/when a Taproot multisig path standard emerges, revisit this and +/// the related helpers in `coincube-gui/src/utils/mod.rs` and +/// `coincube-gui/src/installer/step/descriptor/editor/key.rs`. pub fn default_derivation_path(network: Network) -> bip32::DerivationPath { let coin_type = match network { Network::Bitcoin => 0, @@ -454,4 +498,38 @@ mod tests { let debug_str = format!("{:?}", enrollment); assert!(debug_str.contains("BorderWalletEnrollment")); } + + #[test] + fn test_grid_recovery_phrase_from_master_signer_deterministic() { + let secp = secp256k1::Secp256k1::new(); + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let mnemonic = bip39::Mnemonic::parse_in(bip39::Language::English, phrase).unwrap(); + let signer = MasterSigner::from_mnemonic(Network::Testnet, mnemonic).unwrap(); + + let grp1 = GridRecoveryPhrase::from_master_signer(&signer, &secp).unwrap(); + let grp2 = GridRecoveryPhrase::from_master_signer(&signer, &secp).unwrap(); + + // Same master signer always produces the same grid recovery phrase. + assert_eq!(grp1.as_str(), grp2.as_str()); + + // Must be a valid 12-word BIP39 mnemonic. + let words: Vec<&str> = grp1.as_str().split_whitespace().collect(); + assert_eq!(words.len(), 12); + assert!(bip39::Mnemonic::parse_in(bip39::Language::English, grp1.as_str()).is_ok()); + + // Different master signer produces a different grid phrase. + let other = MasterSigner::generate(Network::Testnet).unwrap(); + let grp3 = GridRecoveryPhrase::from_master_signer(&other, &secp).unwrap(); + assert_ne!(grp1.as_str(), grp3.as_str()); + } + + #[test] + fn test_grid_recovery_phrase_from_master_signer_differs_from_random() { + let secp = secp256k1::Secp256k1::new(); + let signer = MasterSigner::generate(Network::Testnet).unwrap(); + let derived = GridRecoveryPhrase::from_master_signer(&signer, &secp).unwrap(); + // The derived phrase should produce a valid grid. + let grid = derived.generate_grid(); + assert_eq!(grid.cells().len(), WordGrid::TOTAL_CELLS); + } } diff --git a/coincube-core/src/descriptors/mod.rs b/coincube-core/src/descriptors/mod.rs index 1557bcab1..b076b564a 100644 --- a/coincube-core/src/descriptors/mod.rs +++ b/coincube-core/src/descriptors/mod.rs @@ -825,14 +825,14 @@ impl DerivedSinglePathCoincubeDesc { mod tests { use super::*; - use crate::signer::HotSigner; + use crate::signer::MasterSigner; use bitcoin::{hashes::Hash, Sequence}; use miniscript::bitcoin::bip32::Fingerprint; fn random_desc_key( secp: &secp256k1::Secp256k1, ) -> descriptor::DescriptorPublicKey { - let signer = HotSigner::generate(bitcoin::Network::Bitcoin).unwrap(); + let signer = MasterSigner::generate(bitcoin::Network::Bitcoin).unwrap(); let xpub_str = format!( "[{}]{}/<0;1>/*", signer.fingerprint(secp), diff --git a/coincube-core/src/signer.rs b/coincube-core/src/signer.rs index d9e39c290..05cc2dae3 100644 --- a/coincube-core/src/signer.rs +++ b/coincube-core/src/signer.rs @@ -1,7 +1,7 @@ //! Signer module //! //! Some helpers to facilitate the usage of a signer in client of the Coincube daemon. For now -//! only contains a hot signer. +//! only contains a master signer. use crate::random; @@ -102,11 +102,16 @@ impl fmt::Display for SignerError { impl error::Error for SignerError {} pub const MNEMONICS_FOLDER_NAME: &str = "mnemonics"; +/// Label embedded in the checksum portion of mnemonic filenames for master seeds. +pub const MASTER_SEED_LABEL: &str = "master_"; +/// Legacy label kept for backward-compat reading of old Liquid-signer files. +pub const LEGACY_LIQUID_SEED_LABEL: &str = "liquid_"; /// A signer that keeps the key on the laptop. Based on BIP39. -pub struct HotSigner { +pub struct MasterSigner { mnemonic: bip39::Mnemonic, master_xpriv: bip32::Xpriv, + network: bitcoin::Network, } // TODO: instead of copying them here we could have a util module with those helpers. @@ -146,7 +151,7 @@ fn create_file(path: &path::Path) -> Result { }; } -impl HotSigner { +impl MasterSigner { pub fn from_mnemonic( network: bitcoin::Network, mnemonic: bip39::Mnemonic, @@ -156,10 +161,11 @@ impl HotSigner { Ok(Self { mnemonic, master_xpriv, + network, }) } - /// Create a new hot signer from random bytes. Uses a 12-words mnemonics without a passphrase. + /// Create a new master signer from random bytes. Uses a 12-words mnemonics without a passphrase. pub fn generate(network: bitcoin::Network) -> Result { // We want a 12-words mnemonic so we only use 16 of the 32 bytes. let random_32bytes = random::random_bytes().map_err(SignerError::Randomness)?; @@ -168,6 +174,21 @@ impl HotSigner { Self::from_mnemonic(network, mnemonic) } + /// Create a MasterSigner from a 32-byte WebAuthn PRF extension output. + /// + /// The first 16 bytes of the PRF output are used directly as BIP39 entropy, + /// producing a deterministic 12-word mnemonic. This mirrors [`Self::generate`], + /// which also takes the first 16 bytes of 32 random bytes. The same PRF + /// output always yields the same mnemonic and master key. + pub fn from_prf_output( + network: bitcoin::Network, + prf_output: &[u8; 32], + ) -> Result { + let mnemonic = + bip39::Mnemonic::from_entropy(&prf_output[..16]).map_err(SignerError::Mnemonic)?; + Self::from_mnemonic(network, mnemonic) + } + pub fn from_str(network: bitcoin::Network, s: &str) -> Result { let mnemonic = bip39::Mnemonic::from_str(s).map_err(SignerError::Mnemonic)?; Self::from_mnemonic(network, mnemonic) @@ -188,8 +209,8 @@ impl HotSigner { .collect() } - /// Read mnemonics from datadir (with optional password for encrypted files) - /// If `skip_liquid` is true, skip files containing "-liquid-" in the filename (Liquid wallet mnemonics) + /// Read mnemonics from datadir (with optional password for encrypted files). + /// To exclude Liquid/master-seed files, use [`Self::from_datadir_with_password_filtered`]. pub fn from_datadir_with_password( datadir_root: &path::Path, network: bitcoin::Network, @@ -198,12 +219,12 @@ impl HotSigner { Self::from_datadir_with_password_filtered(datadir_root, network, password, false) } - /// Read mnemonics from datadir, optionally filtering out Liquid wallet mnemonics + /// Read mnemonics from datadir, optionally filtering out Liquid-wallet and master-seed mnemonics. pub fn from_datadir_with_password_filtered( datadir_root: &path::Path, network: bitcoin::Network, password: Option<&str>, - skip_liquid: bool, + vault_only: bool, ) -> Result, SignerError> { let mut signers = Vec::new(); @@ -214,10 +235,12 @@ impl HotSigner { for entry in mnemonic_paths { let path = entry.map_err(SignerError::MnemonicStorage)?.path(); - // Skip Liquid wallet mnemonics if requested (they're managed by Breez SDK) - if skip_liquid { + // Skip Liquid and master-seed mnemonics when in vault-only mode. + if vault_only { if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { - if filename.contains("-liquid_") { + if filename.contains(&format!("-{}", LEGACY_LIQUID_SEED_LABEL)) + || filename.contains(&format!("-{}", MASTER_SEED_LABEL)) + { continue; } } @@ -366,10 +389,16 @@ impl HotSigner { result } + /// Reconstructs an equivalent signer by re-deriving from this signer's mnemonic. + /// Useful when a second owner needs the same key material (e.g., the installer + /// re-using the cube's master seed as the vault hot-signer in dev mode). + pub fn try_clone(&self) -> Result { + Self::from_mnemonic(self.network, self.mnemonic.clone()) + } + /// Store the mnemonic in a file within the given "data directory". /// The file is stored within a "mnemonics" folder, with the filename set to the fingerprint of - /// the master xpub corresponding to this mnemonic. - /// Store the mnemonic (encrypted if password provided) + /// the master xpub corresponding to this mnemonic. Encrypted when `password` is provided. pub fn store_encrypted( &self, datadir_root: &path::Path, @@ -858,22 +887,22 @@ mod tests { } #[test] - fn hot_signer_gen() { + fn master_signer_gen() { // Entropy isn't completely broken. assert_ne!( - HotSigner::generate(bitcoin::Network::Bitcoin) + MasterSigner::generate(bitcoin::Network::Bitcoin) .unwrap() .words(), - HotSigner::generate(bitcoin::Network::Bitcoin) + MasterSigner::generate(bitcoin::Network::Bitcoin) .unwrap() .words() ); // Roundtrips. - let signer = HotSigner::generate(bitcoin::Network::Bitcoin).unwrap(); + let signer = MasterSigner::generate(bitcoin::Network::Bitcoin).unwrap(); let mnemonics_str = signer.mnemonic_str(); assert_eq!( - HotSigner::from_str(bitcoin::Network::Bitcoin, &mnemonics_str) + MasterSigner::from_str(bitcoin::Network::Bitcoin, &mnemonics_str) .unwrap() .words(), signer.words() @@ -888,7 +917,34 @@ mod tests { } #[test] - fn hot_signer_storage() { + fn master_signer_from_prf_output_deterministic() { + let prf_output: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let signer1 = + MasterSigner::from_prf_output(bitcoin::Network::Bitcoin, &prf_output).unwrap(); + let signer2 = + MasterSigner::from_prf_output(bitcoin::Network::Bitcoin, &prf_output).unwrap(); + + // Same PRF output must produce the same mnemonic and fingerprint. + assert_eq!(signer1.words(), signer2.words()); + let secp = secp256k1::Secp256k1::signing_only(); + assert_eq!(signer1.fingerprint(&secp), signer2.fingerprint(&secp)); + + // Must produce a valid 12-word mnemonic. + assert_eq!(signer1.words().len(), 12); + + // Different PRF output must produce a different mnemonic. + let other_prf: [u8; 32] = [0xff; 32]; + let signer3 = MasterSigner::from_prf_output(bitcoin::Network::Bitcoin, &other_prf).unwrap(); + assert_ne!(signer1.words(), signer3.words()); + } + + #[test] + fn master_signer_storage() { let secp = secp256k1::Secp256k1::signing_only(); let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); @@ -896,12 +952,12 @@ mod tests { let words_set: HashSet<_> = (0..10) .map(|_| { - let signer = HotSigner::generate(network).unwrap(); + let signer = MasterSigner::generate(network).unwrap(); signer.store(&tmp_dir, network, &secp, None).unwrap(); signer.words() }) .collect(); - let words_read: HashSet<_> = HotSigner::from_datadir(&tmp_dir, network) + let words_read: HashSet<_> = MasterSigner::from_datadir(&tmp_dir, network) .unwrap() .into_iter() .map(|signer| signer.words()) @@ -912,17 +968,17 @@ mod tests { } #[test] - fn hot_signer_sign_p2wsh() { + fn master_signer_sign_p2wsh() { let secp = secp256k1::Secp256k1::new(); let network = bitcoin::Network::Bitcoin; - // Create a Coincube descriptor with as primary path a 2-of-3 with three hot signers and a - // single hot signer as recovery path. (The recovery path signer is also used in the + // Create a Coincube descriptor with as primary path a 2-of-3 with three master signers and a + // single master signer as recovery path. (The recovery path signer is also used in the // primary path.) Use various random derivation paths. let (prim_signer_a, prim_signer_b, recov_signer) = ( - HotSigner::generate(network).unwrap(), - HotSigner::generate(network).unwrap(), - HotSigner::generate(network).unwrap(), + MasterSigner::generate(network).unwrap(), + MasterSigner::generate(network).unwrap(), + MasterSigner::generate(network).unwrap(), ); let origin_der = bip32::DerivationPath::from_str("m/0'/12'/42").unwrap(); let xkey = prim_signer_a.xpub_at(&origin_der, &secp); @@ -1173,17 +1229,17 @@ mod tests { } #[test] - fn hot_signer_sign_taproot() { + fn master_signer_sign_taproot() { let secp = secp256k1::Secp256k1::new(); let network = bitcoin::Network::Bitcoin; - // Create a Coincube descriptor with as primary path a 2-of-3 with three hot signers and a - // single hot signer as recovery path. (The recovery path signer is also used in the + // Create a Coincube descriptor with as primary path a 2-of-3 with three master signers and a + // single master signer as recovery path. (The recovery path signer is also used in the // primary path.) Use various random derivation paths. let (prim_signer_a, prim_signer_b, recov_signer) = ( - HotSigner::generate(network).unwrap(), - HotSigner::generate(network).unwrap(), - HotSigner::generate(network).unwrap(), + MasterSigner::generate(network).unwrap(), + MasterSigner::generate(network).unwrap(), + MasterSigner::generate(network).unwrap(), ); let origin_der = bip32::DerivationPath::from_str("m/0'/12'/42").unwrap(); let xkey = prim_signer_a.xpub_at(&origin_der, &secp); @@ -1495,7 +1551,7 @@ mod tests { #[test] fn signer_set_net() { let secp = secp256k1::Secp256k1::signing_only(); - let mut signer = HotSigner::from_str( + let mut signer = MasterSigner::from_str( bitcoin::Network::Bitcoin, "burger ball theme dog light account produce chest warrior swarm flip equip", ) @@ -1584,4 +1640,12 @@ mod tests { // Invalid timestamp assert!(MnemonicFileName::from_str("mnemonic-abcd1234-def456-notanumber.txt").is_err()); } + + #[test] + fn test_try_clone_fingerprint_matches() { + let secp = secp256k1::Secp256k1::new(); + let signer = MasterSigner::generate(bitcoin::Network::Bitcoin).unwrap(); + let cloned = signer.try_clone().unwrap(); + assert_eq!(signer.fingerprint(&secp), cloned.fingerprint(&secp)); + } } diff --git a/coincube-gui/Cargo.toml b/coincube-gui/Cargo.toml index 56f20a02e..d07f95ecd 100644 --- a/coincube-gui/Cargo.toml +++ b/coincube-gui/Cargo.toml @@ -111,6 +111,42 @@ zip = { version = "0.6", default-features = false, features = [ "deflate", ] } +[target.'cfg(target_os = "macos")'.dependencies] +# objc2 0.6 has the PRF extension bindings (added with macOS 14 support). +# This coexists with the 0.5 version pulled in by iced/winit/wry — we're +# careful not to pass types between the two versions. +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = [ + "NSData", + "NSString", + "NSError", + "NSObject", + "NSArray", + "NSDictionary", +] } +objc2-authentication-services = { version = "0.3", features = [ + "ASFoundation", + "ASAuthorization", + "ASAuthorizationController", + "ASAuthorizationRequest", + "ASAuthorizationCredential", + "ASAuthorizationPlatformPublicKeyCredentialProvider", + "ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest", + "ASAuthorizationPlatformPublicKeyCredentialRegistration", + "ASAuthorizationPlatformPublicKeyCredentialAssertionRequest", + "ASAuthorizationPlatformPublicKeyCredentialAssertion", + "ASAuthorizationPublicKeyCredentialRegistration", + "ASAuthorizationPublicKeyCredentialAssertion", + "ASAuthorizationPublicKeyCredentialRegistrationRequest", + "ASAuthorizationPublicKeyCredentialAssertionRequest", + "ASAuthorizationPublicKeyCredentialPRFRegistrationInput", + "ASAuthorizationPublicKeyCredentialPRFAssertionInput", + "ASAuthorizationPublicKeyCredentialPRFAssertionOutput", + "ASAuthorizationPublicKeyCredentialPRFRegistrationOutput", + "ASPublicKeyCredential", + "ASAuthorizationPublicKeyCredentialDescriptor", +] } + [target.'cfg(unix)'.dependencies] libc = "0.2" tar = { version = "0.4", default-features = false } diff --git a/coincube-gui/src/app/breez/client.rs b/coincube-gui/src/app/breez/client.rs index 8eb0fa282..b635a66cb 100644 --- a/coincube-gui/src/app/breez/client.rs +++ b/coincube-gui/src/app/breez/client.rs @@ -10,7 +10,7 @@ use coincube_core::{ secp256k1::{All, Secp256k1}, Amount, }, - signer::HotSigner, + signer::MasterSigner, }; use std::{ str::FromStr, @@ -21,15 +21,15 @@ use iced::futures::{SinkExt, Stream}; use super::{BreezConfig, BreezError}; -/// Wrapper around HotSigner that implements Breez SDK's Signer trait +/// Wrapper around MasterSigner that implements Breez SDK's Signer trait /// Based on SdkSigner from breez-sdk-liquid -struct HotSignerAdapter { - signer: Arc>, +struct MasterSignerAdapter { + signer: Arc>, secp: Secp256k1, } -impl HotSignerAdapter { - fn new(signer: Arc>) -> Self { +impl MasterSignerAdapter { + fn new(signer: Arc>) -> Self { Self { signer, secp: Secp256k1::new(), @@ -37,7 +37,7 @@ impl HotSignerAdapter { } } -impl breez::Signer for HotSignerAdapter { +impl breez::Signer for MasterSignerAdapter { fn sign_ecdsa( &self, msg: Vec, @@ -169,7 +169,7 @@ impl breez::Signer for HotSignerAdapter { #[derive(Clone)] pub struct BreezClient { sdk: Option>, - signer: Option>>, + signer: Option>>, network: Network, } @@ -177,7 +177,7 @@ impl std::fmt::Debug for BreezClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BreezClient") .field("sdk", &self.sdk.as_ref().map(|_| "")) - .field("signer", &self.signer.as_ref().map(|_| "")) + .field("signer", &self.signer.as_ref().map(|_| "")) .field("network", &self.network) .finish() } @@ -201,12 +201,12 @@ impl BreezClient { .ok_or(BreezError::NetworkNotSupported(self.network)) } - /// Connect to Breez SDK using an external signer (HotSigner) + /// Connect to Breez SDK using an external signer (MasterSigner) pub async fn connect_with_signer( cfg: BreezConfig, - signer: Arc>, + signer: Arc>, ) -> Result { - let signer_adapter = HotSignerAdapter::new(signer.clone()); + let signer_adapter = MasterSignerAdapter::new(signer.clone()); let request = breez::ConnectWithSignerRequest { config: cfg.sdk_config(), @@ -642,7 +642,7 @@ impl BreezClient { .map_err(|e| BreezError::Sdk(e.to_string())) } - pub fn liquid_signer(&self) -> Option>> { + pub fn liquid_signer(&self) -> Option>> { self.signer.clone() } diff --git a/coincube-gui/src/app/breez/mod.rs b/coincube-gui/src/app/breez/mod.rs index f9f145735..c4f8b5369 100644 --- a/coincube-gui/src/app/breez/mod.rs +++ b/coincube-gui/src/app/breez/mod.rs @@ -9,7 +9,7 @@ pub use config::BreezConfig; pub use breez_sdk_liquid::prelude::{GetInfoResponse, ReceivePaymentResponse, SendPaymentResponse}; use coincube_core::miniscript::bitcoin::{bip32::Fingerprint, Network}; -use coincube_core::signer::HotSigner; +use coincube_core::signer::MasterSigner; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -40,13 +40,13 @@ impl std::fmt::Display for BreezError { impl std::error::Error for BreezError {} -/// Load BreezClient from datadir using the Liquid wallet signer fingerprint. +/// Load BreezClient from datadir using the master signer fingerprint. /// Returns `Err(BreezError::NetworkNotSupported)` for non-mainnet/retest networks so /// the caller can create a disconnected `BreezClient` instead of an error. pub async fn load_breez_client( datadir: &Path, network: Network, - liquid_signer_fingerprint: Fingerprint, + master_signer_fingerprint: Fingerprint, password: &str, ) -> Result, BreezError> { // Breez SDK (Liquid) supports mainnet and regtest. Testnet, Testnet4 and @@ -58,17 +58,20 @@ pub async fn load_breez_client( } // Load only the specific signer by fingerprint (more efficient and secure) - let liquid_signer = HotSigner::from_datadir_by_fingerprint( + let liquid_signer = MasterSigner::from_datadir_by_fingerprint( datadir, network, - liquid_signer_fingerprint, + master_signer_fingerprint, Some(password), ) .map_err(|e| match e { coincube_core::signer::SignerError::MnemonicStorage(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => { - BreezError::SignerNotFound(liquid_signer_fingerprint) + BreezError::SignerNotFound(master_signer_fingerprint) + } + coincube_core::signer::SignerError::SignerNotFound(fingerprint) => { + BreezError::SignerNotFound(fingerprint) } _ => BreezError::SignerError(e.to_string()), })?; diff --git a/coincube-gui/src/app/cache.rs b/coincube-gui/src/app/cache.rs index 3568d8537..18c7b5d2c 100644 --- a/coincube-gui/src/app/cache.rs +++ b/coincube-gui/src/app/cache.rs @@ -51,6 +51,13 @@ pub struct Cache { pub has_vault: bool, /// Display name of the current Cube pub cube_name: String, + /// Whether the user has completed the master seed backup flow for this + /// Cube. Drives the soft "not backed up" warning banners on the Vault + /// and Liquid home screens. Mirrors `CubeSettings::backed_up`. + pub current_cube_backed_up: bool, + /// Whether the current Cube uses a passkey-derived master key (no PIN, + /// no stored encrypted mnemonic). Used to hide the seed-backup UI. + pub current_cube_is_passkey: bool, /// Whether the P2P panel is available (requires a valid mnemonic) pub has_p2p: bool, /// Current theme mode (dark/light) — used for theme-aware widget rendering @@ -85,6 +92,8 @@ impl std::default::Default for Cache { connect_authenticated: false, has_vault: false, cube_name: String::new(), + current_cube_backed_up: false, + current_cube_is_passkey: false, has_p2p: false, theme_mode: coincube_ui::theme::palette::ThemeMode::default(), btc_usd_price: None, diff --git a/coincube-gui/src/app/mod.rs b/coincube-gui/src/app/mod.rs index d93ab21af..967593411 100644 --- a/coincube-gui/src/app/mod.rs +++ b/coincube-gui/src/app/mod.rs @@ -747,6 +747,8 @@ impl App { has_vault: false, bitcoin_unit, cube_name: cube_settings.name.clone(), + current_cube_backed_up: cube_settings.backed_up, + current_cube_is_passkey: cube_settings.is_passkey_cube(), ..Default::default() }; @@ -1409,6 +1411,12 @@ impl App { { self.cache.bitcoin_unit = cube.unit_setting.display_unit; self.cube_settings.fiat_price = cube.fiat_price.clone(); + // Keep the "backed up" banner state in sync with + // whatever was persisted — the backup flow saves + // cube.backed_up = true via this same path. + self.cache.current_cube_backed_up = cube.backed_up; + self.cache.current_cube_is_passkey = cube.is_passkey_cube(); + self.cube_settings.backed_up = cube.backed_up; // Clear cached fiat display price if disabled. // Note: btc_usd_price is NOT cleared — it's needed for @@ -1960,6 +1968,25 @@ impl App { return p2p.update(self.daemon.clone(), &self.cache, msg); } } + + // Intercept the mnemonic backup completion so the "not backed up" + // warning banners on the Vault/Liquid home screens disappear + // immediately. Route the message directly to the global settings + // panel (rather than `current_mut()`) so the backup flow still + // transitions to Completed and scrubs `backup_mnemonic` even if + // the user navigated away from Settings before the async write + // resolved. + msg @ Message::View(view::Message::Settings( + view::SettingsMessage::BackupMasterSeedUpdated, + )) => { + self.cache.current_cube_backed_up = true; + self.cube_settings.backed_up = true; + return self + .panels + .global_settings + .update(self.daemon.clone(), &self.cache, msg); + } + msg => { if let (Some(daemon), Some(panel)) = (self.daemon.clone(), self.panels.current_mut()) diff --git a/coincube-gui/src/app/settings/mod.rs b/coincube-gui/src/app/settings/mod.rs index 4a5ceddb0..00eaec714 100644 --- a/coincube-gui/src/app/settings/mod.rs +++ b/coincube-gui/src/app/settings/mod.rs @@ -118,6 +118,23 @@ where Ok(()) } +/// Metadata for a passkey-derived master key (stored in CubeSettings). +/// +/// All fields are non-secret: the credential_id is a public identifier and the +/// rp_id is the relying party domain. The actual PRF output (secret) is never stored. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PasskeyMetadata { + /// Base64-encoded WebAuthn credential ID + pub credential_id: String, + /// Relying Party ID used during registration (e.g., "coincube.io") + pub rp_id: String, + /// Unix timestamp when the passkey was registered + pub created_at: i64, + /// Human-readable label (e.g., "MacBook iCloud Keychain") + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + /// Mark a cube as synced with the remote Connect API. pub async fn mark_cube_synced( network_dir: &NetworkDirectory, @@ -153,8 +170,14 @@ pub struct CubeSettings { /// Optional security PIN (stored as Argon2id hash with salt in PHC format) #[serde(skip_serializing_if = "Option::is_none")] pub security_pin_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub liquid_wallet_signer_fingerprint: Option, + /// Fingerprint of this Cube's master seed MasterSigner. + /// The serde alias keeps existing settings.json files readable without migration. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "liquid_wallet_signer_fingerprint" + )] + pub master_signer_fingerprint: Option, /// Bitcoin display unit preference for this cube #[serde(default)] pub unit_setting: unit::UnitSetting, @@ -164,6 +187,13 @@ pub struct CubeSettings { /// Persisted pending Liquid -> Vault transfer, used to restore UX state across app restarts #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_liquid_to_vault_transfer: Option, + /// Passkey metadata for passkey-derived master keys (None for random-generated keys) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passkey_metadata: Option, + /// When true, the Border Wallet wizard uses a random GridRecoveryPhrase instead + /// of deriving it from the master seed via BIP-85. Defaults to false (use derived). + #[serde(default)] + pub allow_random_grid_phrase: bool, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -185,13 +215,15 @@ impl CubeSettings { created_at: chrono::Utc::now().timestamp(), vault_wallet_id: None, security_pin_hash: None, - liquid_wallet_signer_fingerprint: None, + master_signer_fingerprint: None, backed_up: false, mfa_done: false, remote_synced: false, unit_setting: unit::UnitSetting::default(), fiat_price: Some(fiat::PriceSetting::default()), // Initialize with default (enabled: true) pending_liquid_to_vault_transfer: None, + passkey_metadata: None, + allow_random_grid_phrase: false, } } @@ -204,11 +236,26 @@ impl CubeSettings { self } - pub fn with_liquid_signer(mut self, fingerprint: Fingerprint) -> Self { - self.liquid_wallet_signer_fingerprint = Some(fingerprint); + pub fn with_master_signer(mut self, fingerprint: Fingerprint) -> Self { + self.master_signer_fingerprint = Some(fingerprint); self } + #[deprecated(note = "use with_master_signer")] + pub fn with_liquid_signer(self, fingerprint: Fingerprint) -> Self { + self.with_master_signer(fingerprint) + } + + pub fn with_passkey(mut self, metadata: PasskeyMetadata) -> Self { + self.passkey_metadata = Some(metadata); + self + } + + /// Whether this Cube uses a passkey-derived master key (no PIN, no stored seed). + pub fn is_passkey_cube(&self) -> bool { + self.passkey_metadata.is_some() + } + pub fn with_pin(mut self, pin: &str) -> Result> { self.security_pin_hash = Some(Self::hash_pin(pin)?); Ok(self) @@ -1155,4 +1202,26 @@ mod test { assert!(cube.verify_pin("0000")); assert!(cube.verify_pin("9999")); } + + #[test] + fn test_cube_settings_alias_backward_compat() { + use super::CubeSettings; + + let json = r#"{ + "id": "00000000-0000-0000-0000-000000000001", + "name": "My Cube", + "network": "bitcoin", + "backed_up": false, + "mfa_done": false, + "created_at": 0, + "liquid_wallet_signer_fingerprint": "aabbccdd" + }"#; + + let cube: CubeSettings = serde_json::from_str(json).expect("alias must deserialise"); + assert_eq!( + cube.master_signer_fingerprint.map(|f| f.to_string()), + Some("aabbccdd".to_string()), + "serde alias should map old field name to master_signer_fingerprint" + ); + } } diff --git a/coincube-gui/src/app/state/liquid/overview.rs b/coincube-gui/src/app/state/liquid/overview.rs index fa0586b10..2f37c5ebd 100644 --- a/coincube-gui/src/app/state/liquid/overview.rs +++ b/coincube-gui/src/app/state/liquid/overview.rs @@ -129,7 +129,23 @@ impl State for LiquidOverview { ) .map(view::Message::LiquidOverview); - view::dashboard(menu, cache, send_view) + // Prepend a soft "not backed up" warning banner if the current + // Cube's master seed hasn't been written down yet. The banner + // lives at the state layer (rather than inside the view fn) + // because liquid_overview_view is parameterised on + // `LiquidOverviewMessage`, not the top-level `Message`. + let content: Element = + if !cache.current_cube_backed_up && !cache.current_cube_is_passkey { + Column::new() + .spacing(20) + .push(view::backup_warning_banner()) + .push(send_view) + .into() + } else { + send_view + }; + + view::dashboard(menu, cache, content) } } diff --git a/coincube-gui/src/app/state/liquid/settings.rs b/coincube-gui/src/app/state/liquid/settings.rs index 6f98358e0..3fc5b51de 100644 --- a/coincube-gui/src/app/state/liquid/settings.rs +++ b/coincube-gui/src/app/state/liquid/settings.rs @@ -2,58 +2,23 @@ use std::sync::Arc; use coincube_ui::widget::*; use iced::Task; -use rand::seq::SliceRandom; -use crate::app::settings::{update_settings_file, Settings}; use crate::app::view::LiquidSettingsMessage; use crate::app::{breez::BreezClient, cache::Cache, menu::Menu, state::State}; use crate::app::{message::Message, view, wallet::Wallet}; use crate::daemon::Daemon; -use crate::dir::CoincubeDirectory; -#[derive(Debug, Clone, PartialEq)] -pub enum BackupWalletState { - Intro(bool), - RecoveryPhrase, - Verification { - word_indices: [usize; 3], // Random indices (e.g., [2, 5, 9] but randomized) - word_inputs: [String; 3], // User inputs for the three words - error: Option, - }, - Completed, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum LiquidSettingsFlowState { - MainMenu { backed_up: bool }, - BackupWallet(BackupWalletState), -} - -/// LiquidSettings is a placeholder panel for the Liquid Settings page +/// LiquidSettings panel — Liquid wallet-specific settings. +/// +/// NOTE: The master seed backup flow has been moved to General Settings +/// (Cube-level backup) since the master seed is shared across all wallets. pub struct LiquidSettings { breez_client: Arc, - flow_state: LiquidSettingsFlowState, -} - -/// Generate 3 random unique word indices from 1 to mnemonic_len -/// Returns None if mnemonic_len < 3 -fn generate_random_word_indices(mnemonic_len: usize) -> Option<[usize; 3]> { - if mnemonic_len < 3 { - return None; - } - let mut indices: Vec = (1..=mnemonic_len).collect(); - let mut rng = rand::thread_rng(); - indices.shuffle(&mut rng); - Some([indices[0], indices[1], indices[2]]) } impl LiquidSettings { pub fn new(breez_client: Arc) -> Self { - let backed_up = fetch_main_menu_state(breez_client.clone()); - Self { - breez_client, - flow_state: LiquidSettingsFlowState::MainMenu { backed_up }, - } + Self { breez_client } } } @@ -62,7 +27,7 @@ impl State for LiquidSettings { view::dashboard( menu, cache, - view::liquid::liquid_settings_view(self.breez_client.liquid_signer(), &self.flow_state), + view::liquid::liquid_settings_view(self.breez_client.liquid_signer()), ) } @@ -72,210 +37,10 @@ impl State for LiquidSettings { _cache: &Cache, message: Message, ) -> Task { - match message { - Message::View(view::Message::LiquidSettings(LiquidSettingsMessage::BackupWallet( - backup_msg, - ))) => { - match backup_msg { - view::BackupWalletMessage::ToggleBackupIntroCheck => { - if let LiquidSettingsFlowState::BackupWallet(BackupWalletState::Intro( - checked, - )) = self.flow_state - { - self.flow_state = LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Intro(!checked), - ); - } - } - view::BackupWalletMessage::Start => { - self.flow_state = - LiquidSettingsFlowState::BackupWallet(BackupWalletState::Intro(false)); - } - view::BackupWalletMessage::NextStep => { - self.flow_state = match &self.flow_state { - LiquidSettingsFlowState::BackupWallet(BackupWalletState::Intro( - true, - )) => LiquidSettingsFlowState::BackupWallet( - BackupWalletState::RecoveryPhrase, - ), - LiquidSettingsFlowState::BackupWallet( - BackupWalletState::RecoveryPhrase, - ) => { - let Some(signer) = self.breez_client.liquid_signer() else { - return Task::none(); - }; - let mnemonic = signer.lock().expect("Mutex Lock Poisoned").words(); - - match generate_random_word_indices(mnemonic.len()) { - Some(word_indices) => LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Verification { - word_indices, - word_inputs: [ - String::new(), - String::new(), - String::new(), - ], - error: None, - }, - ), - None => { - tracing::error!("Mnemonic has fewer than 3 words"); - self.flow_state.clone() - } - } - } - _ => self.flow_state.clone(), - }; - } - view::BackupWalletMessage::PreviousStep => { - let backed_up = fetch_main_menu_state(self.breez_client.clone()); - self.flow_state = match &self.flow_state { - LiquidSettingsFlowState::BackupWallet(BackupWalletState::Intro(_)) => { - LiquidSettingsFlowState::MainMenu { backed_up } - } - LiquidSettingsFlowState::BackupWallet( - BackupWalletState::RecoveryPhrase, - ) => LiquidSettingsFlowState::BackupWallet(BackupWalletState::Intro( - false, - )), - LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Verification { .. }, - ) => LiquidSettingsFlowState::BackupWallet( - BackupWalletState::RecoveryPhrase, - ), - LiquidSettingsFlowState::BackupWallet(BackupWalletState::Completed) => { - LiquidSettingsFlowState::MainMenu { backed_up } - } - LiquidSettingsFlowState::MainMenu { backed_up } => { - LiquidSettingsFlowState::MainMenu { - backed_up: *backed_up, - } - } - }; - } - view::BackupWalletMessage::WordInput { index, input } => { - if let LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Verification { - word_indices, - word_inputs, - error, - }, - ) = &self.flow_state - { - // Find which position in our array this index corresponds to - let mut new_inputs = word_inputs.clone(); - if let Some(pos) = - word_indices.iter().position(|&i| i == index as usize) - { - new_inputs[pos] = input; - } - - self.flow_state = LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Verification { - word_indices: *word_indices, - word_inputs: new_inputs, - error: error.clone(), - }, - ); - } - } - view::BackupWalletMessage::VerifyPhrase => { - if let LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Verification { - word_indices, - word_inputs, - .. - }, - ) = &self.flow_state - { - // Get the actual mnemonic words - let Some(signer) = self.breez_client.liquid_signer() else { - return Task::none(); - }; - let mnemonic = signer.lock().expect("Mutex Lock Poisoned").words(); - - // Verify each word matches the correct position in the mnemonic - // word_indices are 1-based, mnemonic array is 0-based - let all_correct = - word_indices.iter().enumerate().all(|(i, &word_idx)| { - if word_idx == 0 || word_idx > mnemonic.len() { - tracing::error!("Invalid word index: {}", word_idx); - return false; - } - word_inputs[i].trim() == mnemonic[word_idx - 1] - }); - - if all_correct { - // Verification successful - self.flow_state = LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Completed, - ); - } else { - // Verification failed - self.flow_state = LiquidSettingsFlowState::BackupWallet( - BackupWalletState::Verification { - word_indices: *word_indices, - word_inputs: word_inputs.clone(), - error: Some( - "The words you entered don't match. Please try again." - .to_string(), - ), - }, - ); - } - } - } - view::BackupWalletMessage::Complete => { - let breez_client = self.breez_client.clone(); - let Some(signer) = breez_client.liquid_signer() else { - return Task::none(); - }; - return Task::perform( - async move { - let secp = - coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); - let fingerprint = signer - .lock() - .expect("Mutex Lock Poisoned") - .fingerprint(&secp); - - let dir = CoincubeDirectory::new_default().map_err(|e| { - format!("Failed to get CoincubeDirectory: {}", e) - })?; - - let network_dir = dir.network_directory(breez_client.network()); - update_settings_file(&network_dir, |mut settings| { - if let Some(cube) = settings.cubes.iter_mut().find(|cube| { - cube.liquid_wallet_signer_fingerprint.as_ref() - == Some(&fingerprint) - }) { - cube.backed_up = true; - } - Some(settings) - }) - .await - .map_err(|e| format!("Failed to update settings file: {}", e))?; - - Ok(()) - }, - |res| match res { - Ok(_) => Message::View(view::Message::LiquidSettings( - view::LiquidSettingsMessage::SettingsUpdated, - )), - Err(e) => Message::View(view::Message::ShowError(e)), - }, - ); - } - } - } - Message::View(view::Message::LiquidSettings( - LiquidSettingsMessage::SettingsUpdated, - )) => { - // Settings file was updated, refresh the state - let backed_up = fetch_main_menu_state(self.breez_client.clone()); - self.flow_state = LiquidSettingsFlowState::MainMenu { backed_up }; - } - _ => {} + if let Message::View(view::Message::LiquidSettings(LiquidSettingsMessage::ExportPayments)) = + message + { + // Export payments handled elsewhere } Task::none() } @@ -285,49 +50,6 @@ impl State for LiquidSettings { _daemon: Option>, _wallet: Option>, ) -> Task { - // Reset to main menu when reloading (e.g., clicking Settings in breadcrumb) - let backed_up = fetch_main_menu_state(self.breez_client.clone()); - self.flow_state = LiquidSettingsFlowState::MainMenu { backed_up }; Task::none() } } - -/// Fetches the main menu state (backed_up) from settings file. -/// Uses spawn_blocking to avoid blocking the async runtime if file I/O hangs. -fn fetch_main_menu_state(breez_client: Arc) -> bool { - // Run blocking I/O in a blocking context to prevent hanging the async runtime - tokio::task::block_in_place(|| { - let mut backed_up = false; - let Some(signer) = breez_client.liquid_signer() else { - return backed_up; - }; - let secp = coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); - let fingerprint = signer - .lock() - .expect("Mutex Lock Poisoned") - .fingerprint(&secp); - - match CoincubeDirectory::new_default() { - Ok(dir) => { - let network_dir = dir.network_directory(breez_client.network()); - match Settings::from_file(&network_dir) { - Ok(settings) => { - let cube = settings.cubes.into_iter().find(|cube| { - cube.liquid_wallet_signer_fingerprint.as_ref() == Some(&fingerprint) - }); - if let Some(cube) = cube { - backed_up = cube.backed_up; - } - } - Err(e) => { - tracing::warn!("Failed to read settings file: {}", e); - } - } - } - Err(e) => { - tracing::error!("Failed to get CoincubeDirectory: {}", e); - } - } - backed_up - }) -} diff --git a/coincube-gui/src/app/state/mod.rs b/coincube-gui/src/app/state/mod.rs index 0b9fc105f..9ed5c85d7 100644 --- a/coincube-gui/src/app/state/mod.rs +++ b/coincube-gui/src/app/state/mod.rs @@ -25,7 +25,7 @@ pub use global_home::GlobalHome; pub use liquid::overview::LiquidOverview; pub use liquid::receive::LiquidReceive; pub use liquid::send::LiquidSend; -pub use liquid::settings::{BackupWalletState, LiquidSettings, LiquidSettingsFlowState}; +pub use liquid::settings::LiquidSettings; pub use liquid::transactions::LiquidTransactions; pub use vault::coins::CoinsPanel; pub use vault::label::LabelsEdited; diff --git a/coincube-gui/src/app/state/settings/general.rs b/coincube-gui/src/app/state/settings/general.rs index 662dd64ca..6a38fabba 100644 --- a/coincube-gui/src/app/state/settings/general.rs +++ b/coincube-gui/src/app/state/settings/general.rs @@ -1,8 +1,11 @@ use std::sync::Arc; -use coincube_core::miniscript::bitcoin::Network; +use coincube_core::miniscript::bitcoin::{bip32::Fingerprint, Network}; +use coincube_core::signer::MasterSigner; use coincube_ui::widget::Element; use iced::Task; +use rand::seq::SliceRandom; +use zeroize::Zeroizing; use crate::app::cache::Cache; use crate::app::error::Error; @@ -10,14 +13,63 @@ use crate::app::menu::Menu; use crate::app::message::{FiatMessage, Message}; use crate::app::settings::fiat::PriceSetting; use crate::app::settings::unit::UnitSetting; -use crate::app::settings::update_settings_file; +use crate::app::settings::{self, update_settings_file}; use crate::app::state::State; use crate::app::view; use crate::app::wallet::Wallet; use crate::daemon::Daemon; use crate::dir::CoincubeDirectory; +use crate::pin_input::PinInput; use crate::services::fiat::currency::Currency; +/// State for the master seed backup flow. +/// +/// Unlike the old Liquid-Settings version, this flow no longer depends on +/// the Breez client / Liquid signer — it works on every network by loading +/// the encrypted mnemonic directly from the datadir using the Cube's PIN. +#[derive(Debug, Clone, PartialEq)] +pub enum BackupSeedState { + /// Not in backup flow. + None, + /// Re-prompt for the Cube PIN before revealing the mnemonic. This is + /// both a security gate and the mechanism by which the encrypted + /// mnemonic file gets decrypted. + PinEntry { + /// Error from the previous verification attempt, if any. + error: Option, + }, + /// Intro screen with security warning and "I understand" checkbox. + Intro(bool), + /// Show the 12 recovery words in a grid. + RecoveryPhrase, + /// Verify the user wrote them down by asking for 3 random words. + Verification { + word_indices: [usize; 3], + word_inputs: [String; 3], + error: Option, + /// True while the async settings.json write is in flight after a + /// successful verification. Suppresses duplicate Verify clicks. + saving: bool, + }, + /// Backup complete — cube.backed_up is now true. + Completed, + /// Passkey re-authentication is required to derive the mnemonic, but the + /// passkey auth ceremony is not yet wired up. Show an informational + /// screen explaining how to back up once passkey auth is available. + PasskeyPending, +} + +/// Generate 3 random unique word indices from 1 to mnemonic_len. +fn generate_random_word_indices(mnemonic_len: usize) -> Option<[usize; 3]> { + if mnemonic_len < 3 { + return None; + } + let mut indices: Vec = (1..=mnemonic_len).collect(); + let mut rng = rand::thread_rng(); + indices.shuffle(&mut rng); + Some([indices[0], indices[1], indices[2]]) +} + async fn update_price_setting( data_dir: CoincubeDirectory, network: Network, @@ -109,6 +161,17 @@ pub struct GeneralSettingsState { developer_mode: bool, show_direction_badges: bool, error: Option, + /// Master seed backup flow state. + pub backup_state: BackupSeedState, + /// PIN re-entry input for the backup flow's PinEntry state. + /// Held as a separate field because `PinInput` doesn't implement + /// `Debug`/`Clone`/`PartialEq` (required by `BackupSeedState`). + pub backup_pin: PinInput, + /// Transient 12-word mnemonic held only while the backup flow is + /// active. Loaded from the datadir via PIN decryption, wiped on + /// flow completion / cancellation. `Zeroizing` ensures the heap + /// memory is scrubbed on drop. + pub backup_mnemonic: Option>>, } impl From for Box { @@ -136,10 +199,328 @@ impl GeneralSettingsState { developer_mode, show_direction_badges, error: None, + backup_state: BackupSeedState::None, + backup_pin: PinInput::new(), + backup_mnemonic: None, + } + } + + /// Look up this Cube in the settings file on disk. + /// + /// Returns the stored `CubeSettings` (which contains the master signer + /// fingerprint and PIN hash) or `None` if the cube can't be found. + fn lookup_cube(&self, cache: &Cache) -> Option { + let network_dir = cache.datadir_path.network_directory(cache.network); + let settings = settings::Settings::from_file(&network_dir).ok()?; + settings.cubes.into_iter().find(|c| c.id == self.cube_id) + } + + /// Handle a single `BackupWalletMessage` — returns the task to dispatch. + fn handle_backup_message( + &mut self, + cache: &Cache, + msg: view::BackupWalletMessage, + ) -> Task { + use view::BackupWalletMessage; + + match msg { + BackupWalletMessage::Start => { + // Passkey-backed cubes derive their mnemonic from the WebAuthn + // PRF output — there is no encrypted mnemonic on disk and no + // PIN. Once passkey re-authentication is implemented we will + // re-derive the mnemonic here; until then, show a helpful + // holding screen. + if let Some(cube) = self.lookup_cube(cache) { + if cube.is_passkey_cube() { + self.backup_state = BackupSeedState::PasskeyPending; + return Task::none(); + } + } + // Always re-prompt for PIN before showing anything sensitive. + self.backup_pin = PinInput::new(); + self.backup_mnemonic = None; + self.backup_state = BackupSeedState::PinEntry { error: None }; + Task::none() + } + BackupWalletMessage::PinInput(pin_msg) => { + // Clear previous error on new input. + if let BackupSeedState::PinEntry { error } = &mut self.backup_state { + *error = None; + } + self.backup_pin.update(pin_msg).map(|m| { + Message::View(view::Message::Settings( + view::SettingsMessage::BackupMasterSeed(BackupWalletMessage::PinInput(m)), + )) + }) + } + BackupWalletMessage::VerifyPin => { + if !matches!(self.backup_state, BackupSeedState::PinEntry { .. }) { + return Task::none(); + } + if !self.backup_pin.is_complete() { + self.backup_state = BackupSeedState::PinEntry { + error: Some("Please enter all 4 PIN digits".to_string()), + }; + return Task::none(); + } + let pin = self.backup_pin.value(); + let Some(cube) = self.lookup_cube(cache) else { + self.backup_state = BackupSeedState::PinEntry { + error: Some("Cube not found in settings".to_string()), + }; + return Task::none(); + }; + let Some(fingerprint) = cube.master_signer_fingerprint else { + self.backup_state = BackupSeedState::PinEntry { + error: Some("This Cube has no master signer.".to_string()), + }; + return Task::none(); + }; + + let datadir = cache.datadir_path.path().to_path_buf(); + let network = cache.network; + + // Run Argon2id PIN verification + mnemonic decryption off + // the UI thread to avoid blocking the event loop. + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + if !cube.verify_pin(&pin) { + return Err("Incorrect PIN. Please try again.".to_string()); + } + load_mnemonic_words(&datadir, network, fingerprint, &pin) + }) + .await + .map_err(|e| format!("PIN verification task failed: {}", e))? + }, + |res| { + Message::View(view::Message::Settings( + view::SettingsMessage::BackupMasterSeed( + view::BackupWalletMessage::PinVerified(res), + ), + )) + }, + ) + } + BackupWalletMessage::PinVerified(result) => { + match result { + Ok(words) => { + self.backup_pin.clear(); + self.backup_mnemonic = Some(Zeroizing::new(words)); + self.backup_state = BackupSeedState::Intro(false); + } + Err(e) => { + self.backup_pin.clear(); + self.backup_state = BackupSeedState::PinEntry { error: Some(e) }; + } + } + Task::none() + } + BackupWalletMessage::ToggleBackupIntroCheck => { + if let BackupSeedState::Intro(checked) = self.backup_state { + self.backup_state = BackupSeedState::Intro(!checked); + } + Task::none() + } + BackupWalletMessage::NextStep => { + self.backup_state = match &self.backup_state { + BackupSeedState::Intro(true) => BackupSeedState::RecoveryPhrase, + BackupSeedState::RecoveryPhrase => { + let mnemonic_len = + self.backup_mnemonic.as_ref().map(|m| m.len()).unwrap_or(0); + match generate_random_word_indices(mnemonic_len) { + Some(word_indices) => BackupSeedState::Verification { + word_indices, + word_inputs: [String::new(), String::new(), String::new()], + error: None, + saving: false, + }, + None => { + tracing::error!("Mnemonic unavailable or has fewer than 3 words"); + self.backup_state.clone() + } + } + } + _ => self.backup_state.clone(), + }; + Task::none() + } + BackupWalletMessage::PreviousStep => { + self.backup_state = match &self.backup_state { + BackupSeedState::PinEntry { .. } => { + self.backup_pin.clear(); + BackupSeedState::None + } + BackupSeedState::Intro(_) => { + // Going back from Intro wipes the loaded mnemonic. + self.backup_mnemonic = None; + BackupSeedState::None + } + BackupSeedState::RecoveryPhrase => BackupSeedState::Intro(false), + BackupSeedState::Verification { .. } => BackupSeedState::RecoveryPhrase, + BackupSeedState::Completed => { + self.backup_mnemonic = None; + BackupSeedState::None + } + BackupSeedState::PasskeyPending => BackupSeedState::None, + BackupSeedState::None => BackupSeedState::None, + }; + Task::none() + } + BackupWalletMessage::WordInput { index, input } => { + if let BackupSeedState::Verification { + word_indices, + word_inputs, + error, + saving, + } = &self.backup_state + { + // Ignore edits while the async save is in flight. + if *saving { + return Task::none(); + } + let mut new_inputs = word_inputs.clone(); + if let Some(pos) = word_indices.iter().position(|&i| i == index as usize) { + new_inputs[pos] = input; + } + self.backup_state = BackupSeedState::Verification { + word_indices: *word_indices, + word_inputs: new_inputs, + error: error.clone(), + saving: false, + }; + } + Task::none() + } + BackupWalletMessage::VerifyPhrase => { + let BackupSeedState::Verification { + word_indices, + word_inputs, + saving, + .. + } = &self.backup_state + else { + return Task::none(); + }; + // Ignore duplicate clicks while the async save is in flight. + if *saving { + return Task::none(); + } + let Some(mnemonic) = &self.backup_mnemonic else { + return Task::none(); + }; + + let all_correct = word_indices.iter().enumerate().all(|(i, &word_idx)| { + if word_idx == 0 || word_idx > mnemonic.len() { + return false; + } + word_inputs[i].trim() == mnemonic[word_idx - 1] + }); + + if all_correct { + // Verification passed — mark the state as saving so the + // Verify button is disabled, then persist + // `backed_up = true` to settings.json. The async result is + // handled by `BackupSaveResult` below: success transitions + // to Completed via `BackupMasterSeedUpdated`, failure + // restores the verification screen with an error message. + let word_indices = *word_indices; + let word_inputs = word_inputs.clone(); + self.backup_state = BackupSeedState::Verification { + word_indices, + word_inputs, + error: None, + saving: true, + }; + let cube_id = self.cube_id.clone(); + let network = cache.network; + let datadir = cache.datadir_path.clone(); + Task::perform( + async move { + let network_dir = datadir.network_directory(network); + update_settings_file(&network_dir, |mut s| { + if let Some(cube) = s.cubes.iter_mut().find(|c| c.id == cube_id) { + cube.backed_up = true; + } + Some(s) + }) + .await + .map_err(|e| format!("Failed to update settings: {}", e)) + }, + |res: Result<(), String>| { + Message::View(view::Message::Settings( + view::SettingsMessage::BackupMasterSeed( + view::BackupWalletMessage::BackupSaveResult(res), + ), + )) + }, + ) + } else { + self.backup_state = BackupSeedState::Verification { + word_indices: *word_indices, + word_inputs: word_inputs.clone(), + error: Some( + "The words you entered don't match. Please try again.".to_string(), + ), + saving: false, + }; + Task::none() + } + } + BackupWalletMessage::BackupSaveResult(res) => { + // The async settings.json write completed. On success, fan out + // `BackupMasterSeedUpdated` so the App-level interceptor can + // refresh `cache.current_cube_backed_up` and the global + // settings panel transitions to Completed (which clears the + // mnemonic). On failure, restore the verification screen with + // an inline error and surface a top-level toast. + match res { + Ok(()) => Task::done(Message::View(view::Message::Settings( + view::SettingsMessage::BackupMasterSeedUpdated, + ))), + Err(e) => { + if let BackupSeedState::Verification { + word_indices, + word_inputs, + .. + } = &self.backup_state + { + self.backup_state = BackupSeedState::Verification { + word_indices: *word_indices, + word_inputs: word_inputs.clone(), + error: Some(format!("Failed to save backup status: {}", e)), + saving: false, + }; + } + Task::done(Message::View(view::Message::ShowError(e))) + } + } + } + BackupWalletMessage::Complete => { + // User dismissed the Completed screen — return to settings. + self.backup_mnemonic = None; + self.backup_state = BackupSeedState::None; + Task::none() + } } } } +/// Load the encrypted mnemonic from the datadir and return the 12 words +/// as a `Vec`. The password is verified by the decryption step — +/// if the PIN is wrong, decryption returns an error. +fn load_mnemonic_words( + datadir: &std::path::Path, + network: Network, + fingerprint: Fingerprint, + pin: &str, +) -> Result, String> { + let signer = + MasterSigner::from_datadir_by_fingerprint(datadir, network, fingerprint, Some(pin)) + .map_err(|e| e.to_string())?; + Ok(signer.words().iter().map(|w| (*w).to_string()).collect()) +} + impl State for GeneralSettingsState { fn view<'a>(&'a self, menu: &'a Menu, cache: &'a Cache) -> Element<'a, view::Message> { crate::app::view::settings::general::general_section( @@ -150,6 +531,9 @@ impl State for GeneralSettingsState { &self.currencies, self.developer_mode, self.show_direction_badges, + &self.backup_state, + &self.backup_pin, + self.backup_mnemonic.as_deref().map(|v| v.as_slice()), ) } @@ -397,6 +781,20 @@ impl State for GeneralSettingsState { format!("Test {} toast", label), ))) } + // --- Master seed backup flow --- + Message::View(view::Message::Settings(view::SettingsMessage::BackupMasterSeed( + backup_msg, + ))) => self.handle_backup_message(cache, backup_msg), + Message::View(view::Message::Settings( + view::SettingsMessage::BackupMasterSeedUpdated, + )) => { + // Cube's backed_up flag has been persisted — transition to + // the Completed screen. Clear the transient PIN input too. + self.backup_state = BackupSeedState::Completed; + self.backup_pin.clear(); + self.backup_mnemonic = None; + Task::none() + } _ => Task::none(), } } diff --git a/coincube-gui/src/app/state/settings/mod.rs b/coincube-gui/src/app/state/settings/mod.rs index ee3db8b5f..d16844095 100644 --- a/coincube-gui/src/app/state/settings/mod.rs +++ b/coincube-gui/src/app/state/settings/mod.rs @@ -1,5 +1,5 @@ mod about; -mod general; +pub mod general; mod install_stats; use std::sync::Arc; diff --git a/coincube-gui/src/app/state/vault/overview.rs b/coincube-gui/src/app/state/vault/overview.rs index 5d079a424..619378d4e 100644 --- a/coincube-gui/src/app/state/vault/overview.rs +++ b/coincube-gui/src/app/state/vault/overview.rs @@ -134,6 +134,7 @@ impl State for VaultOverview { self.processing, &self.sync_status, self.show_rescan_warning, + !cache.current_cube_backed_up && !cache.current_cube_is_passkey, cache.bitcoin_unit, cache.node_bitcoind_sync_progress, cache.node_bitcoind_ibd, diff --git a/coincube-gui/src/app/state/vault/psbt.rs b/coincube-gui/src/app/state/vault/psbt.rs index bd636c2f1..79c61ba90 100644 --- a/coincube-gui/src/app/state/vault/psbt.rs +++ b/coincube-gui/src/app/state/vault/psbt.rs @@ -727,9 +727,12 @@ impl Modal for SignModal { ); } } - Message::View(view::Message::Spend(view::SpendTxMessage::SelectHotSigner)) => { + Message::View(view::Message::Spend(view::SpendTxMessage::SelectMasterSigner)) => { + if let Some(fingerprint) = self.wallet.signer.as_ref().map(|s| s.fingerprint()) { + self.signing.insert(fingerprint); + } return Task::perform( - sign_psbt_with_hot_signer(self.wallet.clone(), tx.psbt.clone()), + sign_psbt_with_master_signer(self.wallet.clone(), tx.psbt.clone()), |(fg, res)| Message::Signed(fg, res), ); } @@ -914,20 +917,22 @@ fn merge_signatures(psbt: &mut Psbt, signed_psbt: &Psbt) { } } -async fn sign_psbt_with_hot_signer( +async fn sign_psbt_with_master_signer( wallet: Arc, psbt: Psbt, ) -> (Fingerprint, Result) { if let Some(signer) = &wallet.signer { let res = signer .sign_psbt(psbt) - .map_err(|e| WalletError::HotSigner(format!("Hot signer failed to sign psbt: {}", e))) + .map_err(|e| { + WalletError::MasterSigner(format!("Master signer failed to sign psbt: {}", e)) + }) .map_err(|e| e.into()); (signer.fingerprint(), res) } else { ( Fingerprint::default(), - Err(WalletError::HotSigner("Hot signer not loaded".to_string()).into()), + Err(WalletError::MasterSigner("Master signer not loaded".to_string()).into()), ) } } diff --git a/coincube-gui/src/app/view/liquid/settings.rs b/coincube-gui/src/app/view/liquid/settings.rs index 677defaa7..49f44ef7c 100644 --- a/coincube-gui/src/app/view/liquid/settings.rs +++ b/coincube-gui/src/app/view/liquid/settings.rs @@ -1,94 +1,22 @@ use std::sync::{Arc, Mutex}; -use coincube_core::signer::HotSigner; -use coincube_ui::{color, component::text::*, widget::*}; -use coincube_ui::{ - icon, - theme::{self}, -}; -use iced::Alignment; -use iced::{widget::container, widget::Column, widget::Row, widget::Space, Length}; - -use crate::app::state::{BackupWalletState, LiquidSettingsFlowState}; -use crate::app::view::message::{BackupWalletMessage, Message}; -use crate::app::view::LiquidSettingsMessage; - -fn header<'a>(title: &'a str) -> Element<'a, Message> { - Row::new() - .spacing(10) - .align_y(Alignment::Center) - .push( - Button::new(text("Settings").size(30).bold()) - .style(theme::button::transparent) - .on_press(Message::Menu(crate::app::menu::Menu::Liquid( - crate::app::menu::LiquidSubMenu::Settings(None), - ))), - ) - .push(icon::chevron_right().size(30)) - .push( - Button::new(text(title).size(30).bold()) - .style(theme::button::transparent) - .on_press(Message::Menu(crate::app::menu::Menu::Liquid( - crate::app::menu::LiquidSubMenu::Settings(None), - ))), - ) - .into() -} - +use coincube_core::signer::MasterSigner; +use coincube_ui::component::text::*; +use coincube_ui::theme; +use coincube_ui::widget::*; +use iced::widget::Column; +use iced::Length; + +use crate::app::view::message::Message; + +/// Liquid wallet settings view. +/// +/// NOTE: The master seed backup flow has been moved to General Settings +/// (Cube-level backup) since the master seed is shared across all wallets. pub fn liquid_settings_view<'a>( - liquid_signer: Option>>, - flow_state: &'a LiquidSettingsFlowState, + _liquid_signer: Option>>, ) -> Element<'a, Message> { - match flow_state { - LiquidSettingsFlowState::MainMenu { backed_up } => main_menu_view(*backed_up), - LiquidSettingsFlowState::BackupWallet(BackupWalletState::Intro(checked)) => { - backup_intro_view(*checked) - } - LiquidSettingsFlowState::BackupWallet(BackupWalletState::RecoveryPhrase) => { - match liquid_signer { - None => error_view(), - Some(signer) => match signer.lock() { - Ok(guard) => recovery_phrase_view(guard.words()), - Err(_) => error_view(), - }, - } - } - LiquidSettingsFlowState::BackupWallet(BackupWalletState::Verification { - word_indices, - word_inputs, - error, - }) => verification_view(word_indices, word_inputs, error.as_deref()), - LiquidSettingsFlowState::BackupWallet(BackupWalletState::Completed) => completed_view(), - } -} - -fn main_menu_view(backed_up: bool) -> Element<'static, Message> { - let backup = settings_section( - "Back up your wallet", - "Protect your wallet by creating and safely storing a recovery phrase.", - icon::lock_icon(), - icon::arrow_right(), - if !backed_up { - CapsuleState::Warning - } else { - CapsuleState::Success - }, - if !backed_up { - icon::warning_icon() - } else { - icon::check_icon() - }, - if !backed_up { - "Not backed up" - } else { - "Completed" - }, - Message::LiquidSettings(LiquidSettingsMessage::BackupWallet( - BackupWalletMessage::Start, - )), - ); - - let header = Button::new(text("Settings").size(30).bold()) + let header = Button::new(text("Liquid Settings").size(30).bold()) .style(theme::button::transparent) .on_press(Message::Menu(crate::app::menu::Menu::Liquid( crate::app::menu::LiquidSubMenu::Settings(None), @@ -98,522 +26,9 @@ fn main_menu_view(backed_up: bool) -> Element<'static, Message> { .spacing(20) .width(Length::Fill) .push(header) - .push(Space::new().height(Length::Fixed(20.0))) - .push(backup) - .into() -} - -fn backup_intro_view(checked: bool) -> Element<'static, Message> { - use coincube_ui::color; - use coincube_ui::theme; - use coincube_ui::widget::{CheckBox, Column, Container, Row, Text}; - use iced::{widget::Space, Alignment, Length}; - let primary_color = color::ORANGE; - Column::new() - .spacing(20) - .width(Length::Fill) - .push(header("Back up your wallet")) - .push(Space::new().height(Length::Fixed(16.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - icon::file_earmark_icon().size(140).color(primary_color) - ) - .push(Space::new().width(Length::Fill)) - ) - .push(Space::new().height(Length::Fixed(16.0))) .push( - Container::new( - Column::new() - .align_x(Alignment::Center) - .push( - Text::new("You will be shown 12 words. Write them down numbered in the same order shown and keep them in a safe place.") - .size(20) - .align_x(iced::alignment::Horizontal::Center) - ) - .push( - Text::new("Do not share these words with anyone.") - .size(20).bold().color(primary_color) - .align_x(iced::alignment::Horizontal::Center) - ) - .push( - Text::new("Without them, you will not be able to restore your wallet if you lose your computer.") - .size(20) - .align_x(iced::alignment::Horizontal::Center) - ) - ) - .padding(20) - .width(Length::Fill) - .align_x(iced::alignment::Horizontal::Center) - ) - .push(Space::new().height(Length::Fixed(16.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - CheckBox::new(checked).label("I UNDERSTAND THAT IF I LOSE THESE WORDS, MY FUNDS CANNOT BE RECOVERED") - .on_toggle(|_| Message::LiquidSettings(LiquidSettingsMessage::BackupWallet(BackupWalletMessage::ToggleBackupIntroCheck))) - .style(theme::checkbox::primary).size(20) - ) - .push(Space::new().width(Length::Fill)) - ) - .push(Space::new().height(Length::Fixed(16.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push({ - let btn: Element<'static, Message> = if checked { - coincube_ui::component::button::primary(None, "Show My Recovery Phrase") - .on_press(Message::LiquidSettings(LiquidSettingsMessage::BackupWallet(BackupWalletMessage::NextStep))) - .padding([8, 16]) - .width(Length::Fixed(300.0)) - .into() - } else { - coincube_ui::component::button::primary(None, "Show My Recovery Phrase") - .padding([8, 16]) - .width(Length::Fixed(300.0)) - .into() - }; - btn - }) - .push(Space::new().width(Length::Fill)) + text("Seed phrase backup has moved to Cube Settings (General section).") + .style(theme::text::secondary), ) .into() } - -fn recovery_phrase_view(mnemonic: [&'static str; 12]) -> Element<'static, Message> { - use coincube_ui::widget::{Container, Row, Text}; - - // Create the mnemonic grid (3 rows x 4 columns) - let mut grid = Column::new().spacing(30).align_x(Alignment::Center); - - for row in 0..3 { - let mut row_widget = Row::new().spacing(40).align_y(Alignment::Center); - - for col in 0..4 { - let index = row * 4 + col; - let word = mnemonic[index]; - - let word_container = Container::new( - Text::new(format!("{}. {}", index + 1, word)) - .size(16) - .align_x(iced::alignment::Horizontal::Center), - ) - .padding(12) - .width(Length::Fixed(150.0)) - .align_x(iced::alignment::Horizontal::Center) - .style(|_theme| container::Style { - border: iced::Border { - color: iced::Color::from_rgb8(0x80, 0x80, 0x80), - width: 1.0, - radius: 10.0.into(), - }, - ..Default::default() - }); - - row_widget = row_widget.push(word_container); - } - - grid = grid.push(row_widget); - } - - Column::new() - .spacing(20) - .width(Length::Fill) - .push(header("Your Recovery Phrase")) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - Container::new( - Text::new("Write these words down in order and keep them offline. Anyone with these words can access your wallet.") - .size(20) - .align_x(iced::alignment::Horizontal::Center) - ) - .width(Length::Fixed(700.0)) - .align_x(iced::alignment::Horizontal::Center) - ) - .push(Space::new().width(Length::Fill)) - ) - .push(Space::new().height(Length::Fixed(24.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push(grid) - .push(Space::new().width(Length::Fill)) - ) - .push(Space::new().height(Length::Fixed(24.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - coincube_ui::component::button::primary(None, "I've Written It Down") - .on_press(Message::LiquidSettings(LiquidSettingsMessage::BackupWallet(BackupWalletMessage::NextStep))) - .padding([8, 16]) - .width(Length::Fixed(300.0)) - ) - .push(Space::new().width(Length::Fill)) - ) - .into() -} - -/// Helper function to create a word input field with a bottom border divider -fn word_input_field<'a, F, S>( - word_num: usize, - word_value: &'a str, - no_border_style: S, - on_input: F, -) -> Element<'a, Message> -where - F: Fn(String) -> Message + 'a, - S: Fn( - &coincube_ui::theme::Theme, - iced::widget::text_input::Status, - ) -> iced::widget::text_input::Style - + 'a, -{ - use coincube_ui::widget::{Text, TextInput}; - - Column::new() - .push( - Row::new() - .spacing(12) - .align_y(Alignment::Center) - .push(Text::new(format!("{}.", word_num)).size(18)) - .push( - TextInput::new("", word_value) - .on_input(on_input) - .padding(8) - .width(Length::Fixed(300.0)) - .style(no_border_style), - ), - ) - .push( - Container::new(Space::new().height(Length::Fixed(0.0))) - .width(Length::Fixed(340.0)) - .height(Length::Fixed(1.0)) - .style(|_theme| container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgb8( - 0x80, 0x80, 0x80, - ))), - ..Default::default() - }), - ) - .into() -} - -fn verification_view<'a>( - word_indices: &'a [usize; 3], - word_inputs: &'a [String; 3], - error: Option<&'a str>, -) -> Element<'a, Message> { - use coincube_ui::widget::{Container, Row, Text}; - - let all_filled = word_inputs.iter().all(|w| !w.is_empty()); - - let mut content = Column::new().spacing(20).width(Length::Fill); - - content = content.push(header("Verify Your Recovery Phrase")); - - // Subheading - content = content.push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - Text::new("To make sure you saved your recovery phrase correctly, please enter the correct words.") - .size(20) - .align_x(iced::alignment::Horizontal::Center) - .width(Length::Fixed(700.0)) - ) - .push(Space::new().width(Length::Fill)) - ); - - content = content.push(Space::new().height(Length::Fixed(24.0))); - - // Error message if verification failed - if let Some(err) = error { - content = - content.push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - Container::new(Text::new(err).size(16).style(|_| { - iced::widget::text::Style { - color: Some(iced::Color::from_rgb8(0xDD, 0x02, 0x02)), - } - })) - .padding(12) - .style(|_theme| container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgb8( - 0x4c, 0x01, 0x01, - ))), - border: iced::Border { - radius: 8.0.into(), - ..Default::default() - }, - ..Default::default() - }), - ) - .push(Space::new().width(Length::Fill)), - ); - content = content.push(Space::new().height(Length::Fixed(16.0))); - } - - // Custom text input style with no border - let no_border_style = |theme: &coincube_ui::theme::Theme, - status: iced::widget::text_input::Status| { - let default_style = theme::text_input::primary(theme, status); - iced::widget::text_input::Style { - border: iced::Border { - width: 0.0, - ..default_style.border - }, - ..default_style - } - }; - - // Input fields with bottom border - dynamically generated based on random indices - let mut input_fields = Column::new().spacing(40).align_x(Alignment::Center); - - for (i, &word_idx) in word_indices.iter().enumerate() { - input_fields = input_fields.push(word_input_field( - word_idx, - &word_inputs[i], - no_border_style, - move |input| { - Message::LiquidSettings(LiquidSettingsMessage::BackupWallet( - BackupWalletMessage::WordInput { - index: word_idx as u8, - input, - }, - )) - }, - )); - } - - content = content.push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push(input_fields) - .push(Space::new().width(Length::Fill)), - ); - - content = content.push(Space::new().height(Length::Fixed(24.0))); - - // Verify button - content = content.push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push(if all_filled { - coincube_ui::component::button::primary(None, "Verify") - .on_press(Message::LiquidSettings( - LiquidSettingsMessage::BackupWallet(BackupWalletMessage::VerifyPhrase), - )) - .padding([8, 16]) - .width(Length::Fixed(300.0)) - } else { - coincube_ui::component::button::primary(None, "Verify") - .padding([8, 16]) - .width(Length::Fixed(300.0)) - }) - .push(Space::new().width(Length::Fill)), - ); - - content.into() -} - -fn error_view() -> Element<'static, Message> { - use coincube_ui::widget::{Column, Row, Text}; - - Column::new() - .spacing(20) - .width(Length::Fill) - .push(header("Error")) - .push(Space::new().height(Length::Fixed(20.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - icon::warning_icon() - .size(100) - .color(coincube_ui::color::RED), - ) - .push(Space::new().width(Length::Fill)), - ) - .push(Space::new().height(Length::Fixed(16.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - Text::new("Unable to access wallet data. Please restart the application.") - .size(20) - .align_x(iced::alignment::Horizontal::Center) - .width(Length::Fixed(700.0)), - ) - .push(Space::new().width(Length::Fill)), - ) - .push(Space::new().height(Length::Fixed(24.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - coincube_ui::component::button::primary(None, "Back to Settings") - .on_press(Message::LiquidSettings( - LiquidSettingsMessage::BackupWallet(BackupWalletMessage::PreviousStep), - )) - .padding([8, 16]), - ) - .push(Space::new().width(Length::Fill)), - ) - .into() -} - -fn completed_view() -> Element<'static, Message> { - use coincube_ui::widget::{Column, Row, Text}; - - let primary_color = coincube_ui::color::ORANGE; - - Column::new() - .spacing(20) - .width(Length::Fill) - .push(header("Backup Complete")) - .push(Space::new().height(Length::Fixed(20.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - icon::check_circle_icon().size(140).color(primary_color) - ) - .push(Space::new().width(Length::Fill)) - ) - .push(Space::new().height(Length::Fixed(16.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - Text::new("Your recovery phrase has been securely backed up. Keep it safe. It's the only way to restore your wallet.") - .size(20) - .align_x(iced::alignment::Horizontal::Center) - .width(Length::Fixed(700.0)) - ) - .push(Space::new().width(Length::Fill)) - ) - .push(Space::new().height(Length::Fixed(24.0))) - .push( - Row::new() - .width(Length::Fill) - .align_y(Alignment::Center) - .push(Space::new().width(Length::Fill)) - .push( - coincube_ui::component::button::primary(None, "Back to Settings") - .on_press(Message::LiquidSettings(LiquidSettingsMessage::BackupWallet(BackupWalletMessage::Complete))) - .padding([8, 16]) - .width(Length::Fixed(300.0)) - ) - .push(Space::new().width(Length::Fill)) - ) - .into() -} - -#[derive(Clone, Copy)] -pub enum CapsuleState { - Warning, - Success, -} - -#[allow(clippy::too_many_arguments)] -fn settings_section<'a>( - title: &'a str, - subtitle: &'a str, - icon: coincube_ui::widget::Text<'a>, - right_icon: coincube_ui::widget::Text<'a>, - capsule_state: CapsuleState, - capsule_icon: coincube_ui::widget::Text<'a>, - capsule_text: &'a str, - msg: Message, -) -> Container<'a, Message> { - Container::new( - Button::new( - Row::new() - .push(icon) - .push( - Column::new() - .push( - Row::new() - .push(text(title).bold()) - .push({ - let pill_style = match capsule_state { - CapsuleState::Warning => { - theme::pill::warning - as fn(&coincube_ui::theme::Theme) -> _ - } - CapsuleState::Success => { - theme::pill::success - as fn(&coincube_ui::theme::Theme) -> _ - } - }; - Container::new( - Row::new() - .push(capsule_icon.size(14).color(color::WHITE)) - .push( - text(capsule_text) - .bold() - .size(14) - .color(color::WHITE), - ) - .spacing(4), - ) - .padding([2, 8]) - .style(pill_style) - }) - .spacing(8), - ) - .push(text(subtitle).small()) - .spacing(2) - .align_x(Alignment::Start), - ) - .push(Space::new().width(Length::Fill)) - .push(right_icon) - .padding(18) - .spacing(20) - .align_y(Alignment::Center) - .width(Length::Fill), - ) - .width(Length::Fill) - .style(theme::button::transparent_border) - .on_press(msg), - ) - .width(Length::Fill) - .style(theme::card::simple) -} diff --git a/coincube-gui/src/app/view/message.rs b/coincube-gui/src/app/view/message.rs index 89b052c66..46d0bc125 100644 --- a/coincube-gui/src/app/view/message.rs +++ b/coincube-gui/src/app/view/message.rs @@ -158,7 +158,7 @@ pub enum SpendTxMessage { Save, Confirm, Cancel, - SelectHotSigner, + SelectMasterSigner, SelectBorderWallet(Fingerprint), BorderWalletRecon(BorderWalletReconMessage), EditPsbt, @@ -227,6 +227,9 @@ pub enum SettingsMessage { InstallStats(InstallStatsViewMessage), TestToast(log::Level), ToggleDirectionBadges(bool), + /// Master seed backup flow (moved from Liquid Settings to Cube/General Settings). + BackupMasterSeed(BackupWalletMessage), + BackupMasterSeedUpdated, } #[derive(Debug, Clone)] @@ -607,12 +610,10 @@ pub enum ReceiveMethod { #[derive(Debug, Clone)] pub enum LiquidSettingsMessage { - BackupWallet(BackupWalletMessage), - SettingsUpdated, ExportPayments, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub enum BackupWalletMessage { ToggleBackupIntroCheck, Start, @@ -620,7 +621,46 @@ pub enum BackupWalletMessage { PreviousStep, VerifyPhrase, Complete, - WordInput { index: u8, input: String }, + WordInput { + index: u8, + input: String, + }, + /// Digit entry in the backup-flow PIN re-verification gate. + PinInput(crate::pin_input::Message), + /// User submits the PIN to unlock the mnemonic. + VerifyPin, + /// Async result of PIN verification + mnemonic decryption. + PinVerified(Result, String>), + /// Async result of persisting `backed_up = true` to settings.json after + /// the user successfully completed the verification step. + BackupSaveResult(Result<(), String>), +} + +// Manual Debug impl to redact mnemonic words and PIN data from logs. +impl std::fmt::Debug for BackupWalletMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ToggleBackupIntroCheck => write!(f, "ToggleBackupIntroCheck"), + Self::Start => write!(f, "Start"), + Self::NextStep => write!(f, "NextStep"), + Self::PreviousStep => write!(f, "PreviousStep"), + Self::VerifyPhrase => write!(f, "VerifyPhrase"), + Self::Complete => write!(f, "Complete"), + Self::WordInput { index, .. } => f + .debug_struct("WordInput") + .field("index", index) + .field("input", &"") + .finish(), + Self::PinInput(_) => f.debug_tuple("PinInput").field(&"").finish(), + Self::VerifyPin => write!(f, "VerifyPin"), + Self::PinVerified(Ok(_)) => write!(f, "PinVerified(Ok())"), + Self::PinVerified(Err(e)) => f + .debug_tuple("PinVerified") + .field(&Err::<(), _>(e)) + .finish(), + Self::BackupSaveResult(res) => f.debug_tuple("BackupSaveResult").field(res).finish(), + } + } } impl From for Message { diff --git a/coincube-gui/src/app/view/mod.rs b/coincube-gui/src/app/view/mod.rs index 021c711f1..490cb88ff 100644 --- a/coincube-gui/src/app/view/mod.rs +++ b/coincube-gui/src/app/view/mod.rs @@ -54,6 +54,35 @@ pub fn balance_header_card<'a, Msg: 'a>(content: impl Into>) -> .into() } +/// A soft "master seed not backed up" warning banner, shown on the Vault +/// and Liquid home screens when `cache.current_cube_backed_up` is false. +/// +/// Tapping the banner routes the user to General Settings where the backup +/// flow lives. This is informational, not blocking — users can dismiss it +/// by completing the backup. +pub fn backup_warning_banner<'a>() -> Element<'a, Message> { + container( + row![ + coincube_ui::icon::warning_icon().style(theme::text::warning), + text::p1_regular( + "Your master seed phrase is not backed up. \ + Back it up now to avoid losing access to your Cube." + ) + .style(theme::text::warning), + Space::new().width(Length::Fill), + button::secondary(None, "Back Up Now").on_press(Message::Menu(Menu::Settings( + crate::app::menu::SettingsSubMenu::General, + ))), + ] + .spacing(10) + .align_y(Alignment::Center), + ) + .padding(15) + .width(Length::Fill) + .style(theme::notification::warning) + .into() +} + fn menu_bar_highlight<'a, T: 'a>() -> Container<'a, T> { Container::new(Space::new().width(Length::Fixed(5.0))) .height(Length::Fixed(50.0)) diff --git a/coincube-gui/src/app/view/settings/backup.rs b/coincube-gui/src/app/view/settings/backup.rs new file mode 100644 index 000000000..af5ec0e13 --- /dev/null +++ b/coincube-gui/src/app/view/settings/backup.rs @@ -0,0 +1,613 @@ +//! Views for the master-seed backup wizard. +//! +//! Rendered as a full-page takeover of the General Settings view when +//! `BackupSeedState != None` (see `general.rs::general_section`). +//! +//! Flow: PinEntry → Intro → RecoveryPhrase → Verification → Completed. + +use coincube_ui::{ + color, + component::{button as ui_button, text::*}, + icon, theme, + widget::*, +}; +use iced::widget::{container, Column, Row, Space}; +use iced::{Alignment, Length}; + +use crate::app::state::settings::general::BackupSeedState; +use crate::app::view::message::{BackupWalletMessage, Message, SettingsMessage}; +use crate::pin_input::PinInput; + +/// Shorthand: wrap a `BackupWalletMessage` in the full `Message` path. +fn wrap(msg: BackupWalletMessage) -> Message { + Message::Settings(SettingsMessage::BackupMasterSeed(msg)) +} + +/// Breadcrumb header for backup wizard screens. +fn header<'a>(title: &'a str) -> Element<'a, Message> { + Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push( + Button::new(text("Settings").size(30).bold()) + .style(theme::button::transparent) + .on_press(Message::Menu(crate::app::menu::Menu::Settings( + crate::app::menu::SettingsSubMenu::General, + ))), + ) + .push(icon::chevron_right().size(30)) + .push( + Button::new(text("Backup").size(30).bold()) + .style(theme::button::transparent) + .on_press(wrap(BackupWalletMessage::PreviousStep)), + ) + .push(icon::chevron_right().size(30)) + .push(text(title).size(30).bold()) + .into() +} + +/// PIN re-entry screen. Shown at the start of the backup flow to gate +/// access to the mnemonic. Also doubles as the decryption password. +pub fn pin_entry_view<'a>(pin: &'a PinInput, error: Option<&'a str>) -> Element<'a, Message> { + let mut col = Column::new() + .spacing(20) + .width(Length::Fill) + .push(header("Enter PIN")) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(icon::lock_icon().size(100).color(color::ORANGE)) + .push(Space::new().width(Length::Fill)), + ) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + Container::new( + text( + "Enter your Cube PIN to unlock and display \ + your 12-word recovery phrase.", + ) + .size(18) + .align_x(iced::alignment::Horizontal::Center), + ) + .width(Length::Fixed(600.0)) + .align_x(iced::alignment::Horizontal::Center), + ) + .push(Space::new().width(Length::Fill)), + ) + .push(Space::new().height(Length::Fixed(24.0))); + + // PIN input widget, routed through BackupMasterSeed(PinInput(..)). + col = col.push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(pin.view().map(|m| wrap(BackupWalletMessage::PinInput(m)))) + .push(Space::new().width(Length::Fill)), + ); + + col = col.push(Space::new().height(Length::Fixed(16.0))); + + if let Some(err) = error { + col = col.push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(text(err).size(16).color(color::RED)) + .push(Space::new().width(Length::Fill)), + ); + col = col.push(Space::new().height(Length::Fixed(8.0))); + } + + col = col.push( + Row::new() + .spacing(20) + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + ui_button::secondary(None, "Cancel") + .on_press(wrap(BackupWalletMessage::PreviousStep)) + .padding([8, 16]) + .width(Length::Fixed(150.0)), + ) + .push({ + let btn = ui_button::primary(None, "Unlock") + .padding([8, 16]) + .width(Length::Fixed(200.0)); + if pin.is_complete() { + btn.on_press(wrap(BackupWalletMessage::VerifyPin)) + } else { + btn + } + }) + .push(Space::new().width(Length::Fill)), + ); + + col.into() +} + +/// Intro screen with security warning + "I understand" checkbox. +pub fn intro_view(checked: bool) -> Element<'static, Message> { + let primary_color = color::ORANGE; + Column::new() + .spacing(20) + .width(Length::Fill) + .push(header("Back up your wallet")) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(icon::file_earmark_icon().size(140).color(primary_color)) + .push(Space::new().width(Length::Fill)) + ) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Container::new( + Column::new() + .align_x(Alignment::Center) + .push( + text("You will be shown 12 words. Write them down numbered in the same order shown and keep them in a safe place.") + .size(20) + .align_x(iced::alignment::Horizontal::Center) + ) + .push( + text("Do not share these words with anyone.") + .size(20).bold().color(primary_color) + .align_x(iced::alignment::Horizontal::Center) + ) + .push( + text("Without them, you will not be able to restore your wallet if you lose your computer.") + .size(20) + .align_x(iced::alignment::Horizontal::Center) + ) + ) + .padding(20) + .width(Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + ) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + CheckBox::new(checked) + .label("I UNDERSTAND THAT IF I LOSE THESE WORDS, MY FUNDS CANNOT BE RECOVERED") + .on_toggle(|_| wrap(BackupWalletMessage::ToggleBackupIntroCheck)) + .style(theme::checkbox::primary) + .size(20) + ) + .push(Space::new().width(Length::Fill)) + ) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Row::new() + .spacing(20) + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + ui_button::secondary(None, "Cancel") + .on_press(wrap(BackupWalletMessage::PreviousStep)) + .padding([8, 16]) + .width(Length::Fixed(150.0)), + ) + .push({ + let btn = ui_button::primary(None, "Show My Recovery Phrase") + .padding([8, 16]) + .width(Length::Fixed(300.0)); + if checked { + btn.on_press(wrap(BackupWalletMessage::NextStep)) + } else { + btn + } + }) + .push(Space::new().width(Length::Fill)) + ) + .into() +} + +/// Show the 12 mnemonic words in a 3×4 grid. +pub fn recovery_phrase_view<'a>(mnemonic: &'a [String]) -> Element<'a, Message> { + let mut grid = Column::new().spacing(30).align_x(Alignment::Center); + + // 3 rows × 4 columns = 12 words + for row in 0..3 { + let mut row_widget = Row::new().spacing(40).align_y(Alignment::Center); + for col in 0..4 { + let index = row * 4 + col; + let word = mnemonic.get(index).map(|s| s.as_str()).unwrap_or("???"); + + let word_container = Container::new( + text(format!("{}. {}", index + 1, word)) + .size(16) + .align_x(iced::alignment::Horizontal::Center), + ) + .padding(12) + .width(Length::Fixed(150.0)) + .align_x(iced::alignment::Horizontal::Center) + .style(|_theme| container::Style { + border: iced::Border { + color: iced::Color::from_rgb8(0x80, 0x80, 0x80), + width: 1.0, + radius: 10.0.into(), + }, + ..Default::default() + }); + + row_widget = row_widget.push(word_container); + } + grid = grid.push(row_widget); + } + + Column::new() + .spacing(20) + .width(Length::Fill) + .push(header("Your Recovery Phrase")) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + Container::new( + text("Write these words down in order and keep them offline. Anyone with these words can access your wallet.") + .size(20) + .align_x(iced::alignment::Horizontal::Center) + ) + .width(Length::Fixed(700.0)) + .align_x(iced::alignment::Horizontal::Center) + ) + .push(Space::new().width(Length::Fill)) + ) + .push(Space::new().height(Length::Fixed(24.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(grid) + .push(Space::new().width(Length::Fill)) + ) + .push(Space::new().height(Length::Fixed(24.0))) + .push( + Row::new() + .spacing(20) + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + ui_button::secondary(None, "Back") + .on_press(wrap(BackupWalletMessage::PreviousStep)) + .padding([8, 16]) + .width(Length::Fixed(150.0)), + ) + .push( + ui_button::primary(None, "I've Written It Down") + .on_press(wrap(BackupWalletMessage::NextStep)) + .padding([8, 16]) + .width(Length::Fixed(300.0)), + ) + .push(Space::new().width(Length::Fill)) + ) + .into() +} + +/// Helper: a labelled word input field with a bottom border. +fn word_input_field<'a, F, S>( + word_num: usize, + word_value: &'a str, + no_border_style: S, + on_input: F, +) -> Element<'a, Message> +where + F: Fn(String) -> Message + 'a, + S: Fn( + &coincube_ui::theme::Theme, + iced::widget::text_input::Status, + ) -> iced::widget::text_input::Style + + 'a, +{ + Column::new() + .push( + Row::new() + .spacing(12) + .align_y(Alignment::Center) + .push(text(format!("{}.", word_num)).size(18)) + .push( + TextInput::new("", word_value) + .on_input(on_input) + .padding(8) + .width(Length::Fixed(300.0)) + .style(no_border_style), + ), + ) + .push( + Container::new(Space::new().height(Length::Fixed(0.0))) + .width(Length::Fixed(340.0)) + .height(Length::Fixed(1.0)) + .style(|_theme| container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgb8( + 0x80, 0x80, 0x80, + ))), + ..Default::default() + }), + ) + .into() +} + +/// Verification screen — ask the user for 3 random words from the mnemonic. +pub fn verification_view<'a>( + word_indices: &'a [usize; 3], + word_inputs: &'a [String; 3], + error: Option<&'a str>, + saving: bool, +) -> Element<'a, Message> { + let all_filled = word_inputs.iter().all(|w| !w.is_empty()); + + let mut content = Column::new().spacing(20).width(Length::Fill); + + content = content.push(header("Verify Your Recovery Phrase")); + + content = content.push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + text("To make sure you saved your recovery phrase correctly, please enter the correct words.") + .size(20) + .align_x(iced::alignment::Horizontal::Center) + .width(Length::Fixed(700.0)) + ) + .push(Space::new().width(Length::Fill)) + ); + + content = content.push(Space::new().height(Length::Fixed(24.0))); + + if let Some(err) = error { + content = content.push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + Container::new(text(err).size(16).color(color::RED)) + .padding(12) + .style(|_theme| container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgb8( + 0x4c, 0x01, 0x01, + ))), + border: iced::Border { + radius: 8.0.into(), + ..Default::default() + }, + ..Default::default() + }), + ) + .push(Space::new().width(Length::Fill)), + ); + content = content.push(Space::new().height(Length::Fixed(16.0))); + } + + // Custom text input style with no border + let no_border_style = |theme: &coincube_ui::theme::Theme, + status: iced::widget::text_input::Status| { + let default_style = theme::text_input::primary(theme, status); + iced::widget::text_input::Style { + border: iced::Border { + width: 0.0, + ..default_style.border + }, + ..default_style + } + }; + + let mut input_fields = Column::new().spacing(40).align_x(Alignment::Center); + + for (i, &word_idx) in word_indices.iter().enumerate() { + input_fields = input_fields.push(word_input_field( + word_idx, + &word_inputs[i], + no_border_style, + move |input| { + wrap(BackupWalletMessage::WordInput { + index: word_idx as u8, + input, + }) + }, + )); + } + + content = content.push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(input_fields) + .push(Space::new().width(Length::Fill)), + ); + + content = content.push(Space::new().height(Length::Fixed(24.0))); + + content = content.push( + Row::new() + .spacing(20) + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + ui_button::secondary(None, "Back") + .on_press(wrap(BackupWalletMessage::PreviousStep)) + .padding([8, 16]) + .width(Length::Fixed(150.0)), + ) + .push({ + let label = if saving { "Saving…" } else { "Verify" }; + let btn = ui_button::primary(None, label) + .padding([8, 16]) + .width(Length::Fixed(300.0)); + if all_filled && !saving { + btn.on_press(wrap(BackupWalletMessage::VerifyPhrase)) + } else { + btn + } + }) + .push(Space::new().width(Length::Fill)), + ); + + content.into() +} + +/// Completed screen — backup is recorded, show a confirmation. +pub fn completed_view() -> Element<'static, Message> { + let primary_color = color::ORANGE; + + Column::new() + .spacing(20) + .width(Length::Fill) + .push(header("Backup Complete")) + .push(Space::new().height(Length::Fixed(20.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(icon::check_circle_icon().size(140).color(primary_color)) + .push(Space::new().width(Length::Fill)) + ) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + text("Your recovery phrase has been securely backed up. Keep it safe. It's the only way to restore your wallet.") + .size(20) + .align_x(iced::alignment::Horizontal::Center) + .width(Length::Fixed(700.0)) + ) + .push(Space::new().width(Length::Fill)) + ) + .push(Space::new().height(Length::Fixed(24.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + ui_button::primary(None, "Back to Settings") + .on_press(wrap(BackupWalletMessage::Complete)) + .padding([8, 16]) + .width(Length::Fixed(300.0)) + ) + .push(Space::new().width(Length::Fill)) + ) + .into() +} + +/// Shown for passkey-derived Cubes. The mnemonic can be re-derived from +/// the WebAuthn PRF output, but passkey re-authentication isn't wired up +/// yet. Tell the user what's going on and how to proceed. +pub fn passkey_pending_view() -> Element<'static, Message> { + Column::new() + .spacing(20) + .width(Length::Fill) + .push(header("Backup via Passkey")) + .push(Space::new().height(Length::Fixed(20.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push(icon::lock_icon().size(100).color(color::ORANGE)) + .push(Space::new().width(Length::Fill)), + ) + .push(Space::new().height(Length::Fixed(16.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + Container::new( + text( + "This Cube uses a passkey-derived master key. \ + To display your 12-word recovery phrase we need to \ + re-authenticate with your passkey — this feature is \ + coming soon. In the meantime, make sure you keep \ + access to the device or security key that holds \ + your passkey.", + ) + .size(18) + .align_x(iced::alignment::Horizontal::Center), + ) + .width(Length::Fixed(600.0)) + .align_x(iced::alignment::Horizontal::Center), + ) + .push(Space::new().width(Length::Fill)), + ) + .push(Space::new().height(Length::Fixed(24.0))) + .push( + Row::new() + .width(Length::Fill) + .align_y(Alignment::Center) + .push(Space::new().width(Length::Fill)) + .push( + ui_button::primary(None, "Back to Settings") + .on_press(wrap(BackupWalletMessage::PreviousStep)) + .padding([8, 16]) + .width(Length::Fixed(300.0)), + ) + .push(Space::new().width(Length::Fill)), + ) + .into() +} + +/// Fallback — shouldn't be visible in the normal flow but useful for +/// debugging state transitions. +pub fn dispatch<'a>( + state: &'a BackupSeedState, + pin: &'a PinInput, + mnemonic: Option<&'a [String]>, +) -> Option> { + match state { + BackupSeedState::None => None, + BackupSeedState::PinEntry { error } => Some(pin_entry_view(pin, error.as_deref())), + BackupSeedState::Intro(checked) => Some(intro_view(*checked)), + BackupSeedState::RecoveryPhrase => { + // Without a loaded mnemonic we can't show anything useful — + // bail back to the normal settings page. This shouldn't happen + // under normal flow because the mnemonic is loaded in VerifyPin. + let mnemonic = mnemonic?; + Some(recovery_phrase_view(mnemonic)) + } + BackupSeedState::Verification { + word_indices, + word_inputs, + error, + saving, + } => Some(verification_view( + word_indices, + word_inputs, + error.as_deref(), + *saving, + )), + BackupSeedState::Completed => Some(completed_view()), + BackupSeedState::PasskeyPending => Some(passkey_pending_view()), + } +} diff --git a/coincube-gui/src/app/view/settings/general.rs b/coincube-gui/src/app/view/settings/general.rs index c5364379e..286e01846 100644 --- a/coincube-gui/src/app/view/settings/general.rs +++ b/coincube-gui/src/app/view/settings/general.rs @@ -14,6 +14,7 @@ use crate::app::view::dashboard; use crate::app::view::message::*; use crate::services::fiat::{Currency, ALL_PRICE_SOURCES}; +#[allow(clippy::too_many_arguments)] pub fn general_section<'a>( menu: &'a Menu, cache: &'a cache::Cache, @@ -22,13 +23,29 @@ pub fn general_section<'a>( currencies_list: &'a [Currency], developer_mode: bool, show_direction_badges: bool, + backup_state: &'a crate::app::state::settings::general::BackupSeedState, + backup_pin: &'a crate::pin_input::PinInput, + backup_mnemonic: Option<&'a [String]>, ) -> Element<'a, Message> { + use crate::app::state::settings::general::BackupSeedState; + + // When the backup flow is active, take over the entire settings page + // with the wizard view. This matches the UX the old Liquid Settings + // backup used and keeps the multi-step flow focused. + if !matches!(backup_state, BackupSeedState::None) { + if let Some(wizard) = super::backup::dispatch(backup_state, backup_pin, backup_mnemonic) { + return dashboard(menu, cache, Column::new().spacing(20).push(wizard)); + } + } + + // Normal settings rendering. let mut col = Column::new() .spacing(20) .push(super::header("General", SettingsMessage::GeneralSection)) .push(bitcoin_display_unit(new_unit_setting)) .push(direction_badges_toggle(show_direction_badges)) - .push(fiat_price(new_price_setting, currencies_list)); + .push(fiat_price(new_price_setting, currencies_list)) + .push(backup_master_seed_card(cache.current_cube_backed_up)); if developer_mode { col = col.push(toast_testing()); @@ -37,6 +54,46 @@ pub fn general_section<'a>( dashboard(menu, cache, col) } +/// The "Backup Master Seed Phrase" card shown on the normal General +/// Settings page. Shows a different label depending on whether the +/// current cube has already been backed up. +fn backup_master_seed_card<'a>(backed_up: bool) -> Element<'a, Message> { + let (title, subtitle, button_label) = if backed_up { + ( + "Master Seed Phrase Backed Up", + "You've already recorded your recovery phrase. You can view it again if needed.", + "View Again", + ) + } else { + ( + "Backup Master Seed Phrase", + "Write down your 12-word recovery phrase as a backup. This is the only way to recover your Cube if you forget your PIN.", + "Start Backup", + ) + }; + + card::simple( + Row::new() + .spacing(20) + .align_y(Alignment::Center) + .push( + Column::new() + .spacing(4) + .push(text(title).bold()) + .push(text(subtitle).size(14)), + ) + .push(Space::new().width(Length::Fill)) + .push( + iced::widget::Button::new(text(button_label).bold()) + .padding([8, 16]) + .style(theme::button::secondary) + .on_press(SettingsMessage::BackupMasterSeed(BackupWalletMessage::Start).into()), + ), + ) + .width(Length::Fill) + .into() +} + fn direction_badges_toggle<'a>(show: bool) -> Element<'a, Message> { card::simple( Row::new() diff --git a/coincube-gui/src/app/view/settings/mod.rs b/coincube-gui/src/app/view/settings/mod.rs index a55e1303c..31d14537c 100644 --- a/coincube-gui/src/app/view/settings/mod.rs +++ b/coincube-gui/src/app/view/settings/mod.rs @@ -1,4 +1,5 @@ pub mod about; +pub mod backup; pub mod general; pub mod install_stats; diff --git a/coincube-gui/src/app/view/vault/overview.rs b/coincube-gui/src/app/view/vault/overview.rs index 8ca27d620..e9b98764d 100644 --- a/coincube-gui/src/app/view/vault/overview.rs +++ b/coincube-gui/src/app/view/vault/overview.rs @@ -82,6 +82,7 @@ pub fn vault_overview_view<'a>( processing: bool, sync_status: &SyncStatus, show_rescan_warning: bool, + show_backup_warning: bool, bitcoin_unit: BitcoinDisplayUnit, node_bitcoind_sync_progress: Option, node_bitcoind_ibd: Option, @@ -174,6 +175,7 @@ pub fn vault_overview_view<'a>( ), )) .push(show_rescan_warning.then_some(rescan_warning())) + .push(show_backup_warning.then(crate::app::view::backup_warning_banner)) .push(match (node_bitcoind_ibd, node_bitcoind_sync_progress) { (Some(true), Some(progress)) => Some( Container::new( diff --git a/coincube-gui/src/app/view/vault/psbt.rs b/coincube-gui/src/app/view/vault/psbt.rs index 9c6fe1290..257f75b01 100644 --- a/coincube-gui/src/app/view/vault/psbt.rs +++ b/coincube-gui/src/app/view/vault/psbt.rs @@ -1050,15 +1050,15 @@ pub fn sign_action<'a>( let can_sign = descriptor.contains_fingerprint_in_path(fingerprint, recovery_timelock); let btn = Button::new(if signed.contains(&fingerprint) { - hw::sign_success_hot_signer(fingerprint, signer_alias) + hw::sign_success_master_signer(fingerprint, signer_alias) } else { - hw::hot_signer(fingerprint, signer_alias, can_sign) + hw::master_signer(fingerprint, signer_alias, can_sign) }) .padding(10) .style(theme::button::secondary) .width(Length::Fill); if can_sign { - btn.on_press(Message::Spend(SpendTxMessage::SelectHotSigner)) + btn.on_press(Message::Spend(SpendTxMessage::SelectMasterSigner)) } else { btn } diff --git a/coincube-gui/src/app/wallet.rs b/coincube-gui/src/app/wallet.rs index c40bae675..1a7e8b524 100644 --- a/coincube-gui/src/app/wallet.rs +++ b/coincube-gui/src/app/wallet.rs @@ -6,7 +6,7 @@ use crate::{ app::settings, daemon::DaemonBackend, hw::HardwareWalletConfig, node::NodeType, signer::Signer, }; -use coincube_core::{miniscript::bitcoin, signer::HotSigner}; +use coincube_core::{miniscript::bitcoin, signer::MasterSigner}; use coincube_core::descriptors::CoincubeDescriptor; use coincube_core::miniscript::bitcoin::bip32::Fingerprint; @@ -151,27 +151,28 @@ impl Wallet { network: bitcoin::Network, ) -> Result { // Load only Vault mnemonics, skip Liquid wallet mnemonics (managed by Breez SDK) - let hot_signers = match HotSigner::from_datadir_vault_only(datadir_path.path(), network) { - Ok(signers) => signers, - Err(e) => match e { - coincube_core::signer::SignerError::MnemonicStorage(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - Vec::new() - } else { - return Err(WalletError::HotSigner(e.to_string())); + let master_signers = + match MasterSigner::from_datadir_vault_only(datadir_path.path(), network) { + Ok(signers) => signers, + Err(e) => match e { + coincube_core::signer::SignerError::MnemonicStorage(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + Vec::new() + } else { + return Err(WalletError::MasterSigner(e.to_string())); + } } - } - _ => return Err(WalletError::HotSigner(e.to_string())), - }, - }; + _ => return Err(WalletError::MasterSigner(e.to_string())), + }, + }; let curve = bitcoin::secp256k1::Secp256k1::signing_only(); let keys = self.descriptor_keys(); - if let Some(hot_signer) = hot_signers + if let Some(master_signer) = master_signers .into_iter() .find(|s| keys.contains(&s.fingerprint(&curve))) { - Ok(self.with_signer(Signer::new(hot_signer))) + Ok(self.with_signer(Signer::new(master_signer))) } else { Ok(self) } @@ -206,7 +207,7 @@ impl Wallet { pub enum WalletError { WrongWalletLoaded, Settings(settings::SettingsError), - HotSigner(String), + MasterSigner(String), BorderWallet(String), } @@ -215,7 +216,7 @@ impl std::fmt::Display for WalletError { match self { Self::WrongWalletLoaded => write!(f, "Wrong wallet was loaded"), Self::Settings(e) => write!(f, "Failed to load settings: {}", e), - Self::HotSigner(e) => write!(f, "Failed to load hot signer: {}", e), + Self::MasterSigner(e) => write!(f, "Failed to load master signer: {}", e), Self::BorderWallet(e) => write!(f, "Border wallet signing failed: {}", e), } } diff --git a/coincube-gui/src/feature_flags.rs b/coincube-gui/src/feature_flags.rs new file mode 100644 index 000000000..06d6124c6 --- /dev/null +++ b/coincube-gui/src/feature_flags.rs @@ -0,0 +1,92 @@ +//! Compile-time feature flags driven by the `.env` file (via `build.rs`). +//! +//! These are not Cargo features — they're string environment variables read +//! at build time via [`option_env!`] so they can be toggled per-build without +//! a recompile of dependencies. +//! +//! `build.rs` forwards every key from the project-root `.env` file through as +//! `cargo:rustc-env=KEY=VALUE`, so each key becomes visible to `option_env!` +//! during compilation of this crate. + +/// Whether the passkey-based Cube creation flow is enabled. +/// +/// Controlled by the `COINCUBE_ENABLE_PASSKEY` env var at build time. +/// Defaults to `false`. When `false`: +/// +/// - The "Use Passkey" toggle is hidden from the Create Cube form. +/// - `Launcher::passkey_mode` is forced to `false` on init and after dismiss. +/// - The `CreateCube` handler's passkey branch becomes dead code. +/// - All passkey service code still compiles but is unreachable. +/// +/// When `true`, the existing passkey code path re-activates (webview on +/// non-macOS, native AuthenticationServices on macOS). +pub const PASSKEY_ENABLED: bool = is_truthy(option_env!("COINCUBE_ENABLE_PASSKEY")); + +/// `const`-compatible truthy check: accepts `"1"`, `"true"`, `"yes"` +/// (case-insensitive for the latter two). Anything else, including `None`, +/// is `false`. +/// +/// Uses byte-slice comparison because `str` equality is not yet stable in +/// `const` contexts on stable Rust. +const fn is_truthy(val: Option<&str>) -> bool { + let Some(s) = val else { + return false; + }; + let b = s.as_bytes(); + bytes_eq_ci(b, b"1") || bytes_eq_ci(b, b"true") || bytes_eq_ci(b, b"yes") +} + +/// Case-insensitive byte-slice equality, const-stable. +const fn bytes_eq_ci(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + let ac = a[i]; + let bc = b[i]; + // Lowercase ASCII letters (bit 0x20 set on A-Z) + let al = if ac >= b'A' && ac <= b'Z' { + ac | 0x20 + } else { + ac + }; + let bl = if bc >= b'A' && bc <= b'Z' { + bc | 0x20 + } else { + bc + }; + if al != bl { + return false; + } + i += 1; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_truthy_accepts_known_values() { + assert!(is_truthy(Some("1"))); + assert!(is_truthy(Some("true"))); + assert!(is_truthy(Some("TRUE"))); + assert!(is_truthy(Some("True"))); + assert!(is_truthy(Some("yes"))); + assert!(is_truthy(Some("YES"))); + } + + #[test] + fn is_truthy_rejects_unknown_values() { + assert!(!is_truthy(None)); + assert!(!is_truthy(Some(""))); + assert!(!is_truthy(Some("0"))); + assert!(!is_truthy(Some("false"))); + assert!(!is_truthy(Some("no"))); + assert!(!is_truthy(Some("on"))); + assert!(!is_truthy(Some("off"))); + assert!(!is_truthy(Some("2"))); + } +} diff --git a/coincube-gui/src/gui/tab.rs b/coincube-gui/src/gui/tab.rs index 16a36cee8..1beeaf6a4 100644 --- a/coincube-gui/src/gui/tab.rs +++ b/coincube-gui/src/gui/tab.rs @@ -162,6 +162,7 @@ impl Tab { } pub fn update(&mut self, message: Message) -> Task { + use crate::app::settings::global::GlobalSettings; let result = match (&mut self.state, message) { (State::Launcher(l), Message::Launch(msg)) => match msg { launcher::Message::Install(datadir, network, init) => { @@ -178,13 +179,46 @@ impl Tab { } } let (install, command) = - Installer::new(datadir, network, None, init, false, None, None); + Installer::new(datadir, network, None, init, false, None, None, false); self.state = State::Installer(install); command.map(Message::Install) } launcher::Message::Run(datadir_path, cfg, network, cube) => { - // PIN is always required - determine what to do after PIN verification - // Try to load Vault wallet settings if cube has a vault configured + if cube.is_passkey_cube() { + // Passkey Cubes don't have an encrypted mnemonic on + // disk — their master seed is re-derived from the + // WebAuthn PRF output on every open. That path isn't + // wired up yet (blocked on macOS code signing + + // associated-domains entitlement), so the only way + // to actually open a passkey Cube right now is via + // the mnemonic recovery flow. + // + // Refuse to open, surface a clear error to the user, + // and stay on the launcher. This prevents falling + // through to the PinEntry state and crashing on the + // (missing) mnemonic load. + tracing::warn!( + "Refusing to open passkey Cube '{}' — passkey auth flow is not \ + wired up. The user must restore from their mnemonic backup.", + cube.name + ); + let msg = if crate::feature_flags::PASSKEY_ENABLED { + "This Cube was created with a passkey. Passkey authentication \ + on Cube open is not yet implemented. Restore from your mnemonic \ + backup to access this Cube." + .to_string() + } else { + "This Cube was created with a passkey, but the passkey feature \ + is currently disabled. Restore from your mnemonic backup to \ + access this Cube, or re-enable COINCUBE_ENABLE_PASSKEY in your \ + environment." + .to_string() + }; + l.set_error(msg); + return Task::none(); + } + + // PIN entry let wallet_settings = cube.vault_wallet_id.as_ref().and_then(|vault_id| { let network_dir = datadir_path.network_directory(network); app::settings::Settings::from_file(&network_dir) @@ -229,6 +263,7 @@ impl Tab { false, None, None, // No breez_client from login screen + false, ); self.state = State::Installer(install); command.map(Message::Install) @@ -403,6 +438,9 @@ impl Tab { true, // launched from app (loader is part of app flow) Some(loader.cube_settings.clone()), // pass cube settings for returning loader.breez_client.clone(), // pass breez_client to avoid re-entering PIN + GlobalSettings::load_developer_mode(&GlobalSettings::path( + &loader.datadir_path, + )), ); self.state = State::Installer(install); command.map(Message::Install) @@ -537,6 +575,9 @@ impl Tab { true, // launched from app Some(app.cube_settings().clone()), // pass cube settings for returning Some(app.breez_client()), // pass breez_client to avoid re-entering PIN + GlobalSettings::load_developer_mode(&GlobalSettings::path( + app.datadir(), + )), ); self.state = State::Installer(install); command.map(Message::Install) @@ -573,21 +614,20 @@ impl Tab { Task::perform( async move { // Load BreezClient for Liquid wallet with PIN - let breez_result = if let Some(fingerprint) = - cube.liquid_wallet_signer_fingerprint - { - breez::load_breez_client( - datadir_clone.path(), - network_val, - fingerprint, - &pin, - ) - .await - } else { - Err(breez::BreezError::SignerError( - "No Liquid wallet configured".to_string(), - )) - }; + let breez_result = + if let Some(fingerprint) = cube.master_signer_fingerprint { + breez::load_breez_client( + datadir_clone.path(), + network_val, + fingerprint, + &pin, + ) + .await + } else { + Err(breez::BreezError::SignerError( + "No Liquid wallet configured".to_string(), + )) + }; ( config_clone, @@ -1017,6 +1057,8 @@ pub fn create_app_with_remote_backend( connect_authenticated: false, has_vault: true, cube_name: cube_settings.name.clone(), + current_cube_backed_up: cube_settings.backed_up, + current_cube_is_passkey: cube_settings.is_passkey_cube(), has_p2p: false, // Set later by App::new based on mnemonic availability theme_mode: coincube_ui::theme::palette::ThemeMode::default(), btc_usd_price: None, diff --git a/coincube-gui/src/installer/descriptor.rs b/coincube-gui/src/installer/descriptor.rs index 34cfc7478..a6a0604ac 100644 --- a/coincube-gui/src/installer/descriptor.rs +++ b/coincube-gui/src/installer/descriptor.rs @@ -16,8 +16,8 @@ const ENABLE_COSIGNER_KEYS: bool = false; pub enum KeySource { /// A hardware signing device with the given kind and version. Device(DeviceKind, Option), - /// A hot signer on the user's computer. - HotSigner, + /// Master signer on the user's computer. + MasterSigner, /// A manually inserted xpub. Manual, /// A token for a key with the given kind. @@ -62,7 +62,7 @@ impl KeySource { pub fn kind(&self) -> KeySourceKind { match self { Self::Device(_, _) => KeySourceKind::Device, - Self::HotSigner => KeySourceKind::HotSigner, + Self::MasterSigner => KeySourceKind::MasterSigner, Self::Manual => KeySourceKind::Manual, Self::Token(kind, _) => KeySourceKind::Token(*kind), Self::BorderWallet => KeySourceKind::BorderWallet, @@ -99,8 +99,8 @@ impl KeySource { pub enum KeySourceKind { /// A hardware signing device. Device, - /// A hot signer. - HotSigner, + /// Master signer. + MasterSigner, /// A manually inserted xpub. Manual, /// A token for a key with the given kind. diff --git a/coincube-gui/src/installer/message.rs b/coincube-gui/src/installer/message.rs index 0e54d2e5f..988000371 100644 --- a/coincube-gui/src/installer/message.rs +++ b/coincube-gui/src/installer/message.rs @@ -47,7 +47,7 @@ pub enum Message { Close, Reload, Select(usize), - UseHotSigner, + UseMasterSigner, Installed(settings::WalletId, Result), CreateTaprootDescriptor(bool), SelectDescriptorTemplate(context::DescriptorTemplate), diff --git a/coincube-gui/src/installer/mod.rs b/coincube-gui/src/installer/mod.rs index fbf71f88d..03651ed46 100644 --- a/coincube-gui/src/installer/mod.rs +++ b/coincube-gui/src/installer/mod.rs @@ -94,6 +94,8 @@ pub struct Installer { /// Pre-loaded BreezClient when launched from app (avoids re-entering PIN) pub breez_client: Option>, + + pub developer_mode: bool, } impl Installer { @@ -127,6 +129,7 @@ impl Installer { Task::none() } + #[allow(clippy::too_many_arguments)] pub fn new( destination_path: CoincubeDirectory, network: bitcoin::Network, @@ -135,8 +138,32 @@ impl Installer { launched_from_app: bool, cube_settings: Option, breez_client: Option>, + mut developer_mode: bool, ) -> (Installer, Task) { - let signer = Arc::new(Mutex::new(Signer::generate(network).unwrap())); + let signer = if developer_mode { + let master_signer = breez_client + .as_ref() + .and_then(|bc| bc.liquid_signer()) + .and_then(|arc_hs| { + arc_hs + .lock() + .ok() + .and_then(|hs_guard| hs_guard.try_clone().ok()) + .map(|hs| Arc::new(Mutex::new(Signer::new(hs)))) + }); + if let Some(ms) = master_signer { + ms + } else { + tracing::warn!( + "developer_mode=true but master signer unavailable; \ + downgrading to normal mode" + ); + developer_mode = false; + Arc::new(Mutex::new(Signer::generate(network).unwrap())) + } + } else { + Arc::new(Mutex::new(Signer::generate(network).unwrap())) + }; let context = Context::new( network, destination_path.clone(), @@ -165,7 +192,7 @@ impl Installer { UserFlow::CreateWallet => vec![ ChooseDescriptorTemplate::default().into(), DescriptorTemplateDescription::default().into(), - DefineDescriptor::new(network, signer.clone()).into(), + DefineDescriptor::new(network, signer.clone(), developer_mode).into(), BackupMnemonic::new(signer.clone()).into(), BackupDescriptor::default().into(), RegisterDescriptor::new_create_wallet().into(), @@ -195,6 +222,7 @@ impl Installer { }, context, signer, + developer_mode, }; // skip the step according to the current context. installer.skip_steps(); @@ -516,7 +544,7 @@ pub async fn install_local_wallet( ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; - info!("Hot signer mnemonic stored"); + info!("Master signer mnemonic stored"); } if let Some(signer) = &ctx.recovered_signer { @@ -598,7 +626,7 @@ pub async fn create_remote_wallet( ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; - info!("Hot signer mnemonic stored"); + info!("Master signer mnemonic stored"); } if let Some(signer) = &ctx.recovered_signer { diff --git a/coincube-gui/src/installer/step/descriptor/editor/border_wallet_wizard.rs b/coincube-gui/src/installer/step/descriptor/editor/border_wallet_wizard.rs index 56cea2b91..e4d2108ce 100644 --- a/coincube-gui/src/installer/step/descriptor/editor/border_wallet_wizard.rs +++ b/coincube-gui/src/installer/step/descriptor/editor/border_wallet_wizard.rs @@ -29,6 +29,8 @@ use coincube_ui::{ widget::{Button, Column, Container, Element, Row, TextInput}, }; +use std::sync::{Arc, Mutex}; + use crate::{ hw::HardwareWallets, installer::{ @@ -36,6 +38,7 @@ use crate::{ message::{self, Message}, step::descriptor::editor::key::SelectedKey, }, + signer::Signer, }; /// Wizard step. @@ -102,11 +105,22 @@ pub struct BorderWalletWizard { checksum_word: Option, enrollment: Option, + /// When true, allow random phrase generation. When false (default), + /// the "Generate" button derives from the master signer via BIP-85. + allow_random_grid_phrase: bool, + /// Master signer for BIP-85 grid phrase derivation. + signer: Option>>, + error: Option, } impl BorderWalletWizard { - pub fn new(network: Network, coordinates: Vec<(usize, usize)>) -> Self { + pub fn new( + network: Network, + coordinates: Vec<(usize, usize)>, + signer: Arc>, + allow_random_grid_phrase: bool, + ) -> Self { Self { network, coordinates, @@ -117,6 +131,8 @@ impl BorderWalletWizard { pattern: OrderedPattern::new(), checksum_word: None, enrollment: None, + allow_random_grid_phrase, + signer: Some(signer), error: None, } } @@ -216,7 +232,30 @@ impl BorderWalletWizard { } fn on_generate_phrase(&mut self) -> Task { - match GridRecoveryPhrase::generate() { + // Default: derive from master signer via BIP-85. + // Fallback to random if allow_random_grid_phrase is true or signer is unavailable. + let result = if !self.allow_random_grid_phrase { + if let Some(signer) = &self.signer { + match signer.lock() { + Ok(signer) => signer + .derive_grid_recovery_phrase() + .map_err(|e| format!("{:?}", e)), + Err(e) => { + self.error = Some(format!( + "Failed to generate recovery phrase: signer lock poisoned: {}", + e + )); + return Task::none(); + } + } + } else { + GridRecoveryPhrase::generate().map_err(|e| format!("{:?}", e)) + } + } else { + GridRecoveryPhrase::generate().map_err(|e| format!("{:?}", e)) + }; + + match result { Ok(rp) => { let words: Vec<&str> = rp.as_str().split_whitespace().collect(); for (i, word) in words.iter().enumerate() { @@ -231,8 +270,8 @@ impl BorderWalletWizard { self.phrase_valid = true; self.error = None; } - Err(_) => { - self.error = Some("Failed to generate recovery phrase.".to_string()); + Err(e) => { + self.error = Some(format!("Failed to generate recovery phrase: {}", e)); } } Task::none() @@ -358,7 +397,12 @@ impl BorderWalletWizard { let back_btn = button::transparent(Some(icon::previous_icon()), "Back") .on_press(self.wizard_msg(BorderWalletWizardMessage::Previous)); - let generate_btn = button::secondary(None, "Generate New Phrase") + let generate_label = if self.allow_random_grid_phrase { + "Generate Random Phrase" + } else { + "Derive from Master Key" + }; + let generate_btn = button::secondary(None, generate_label) .on_press(self.wizard_msg(BorderWalletWizardMessage::GeneratePhrase)) .width(Length::Fill); diff --git a/coincube-gui/src/installer/step/descriptor/editor/key.rs b/coincube-gui/src/installer/step/descriptor/editor/key.rs index 6fb0b327c..2958dcadc 100644 --- a/coincube-gui/src/installer/step/descriptor/editor/key.rs +++ b/coincube-gui/src/installer/step/descriptor/editor/key.rs @@ -101,7 +101,7 @@ enum Focus { Device(Fingerprint), EnterXpub, LoadXpubFromFile, - GenerateHotKey, + GenerateMasterKey, EnterSafetyNetToken, EnterCosignerToken, } @@ -115,8 +115,8 @@ pub enum SelectKeySourceMessage { SelectEnterXpub, PasteXpub, Xpub(String), - SelectGenerateHotKey, - FetchFromHotSigner(ChildNumber), + SelectGenerateMasterKey, + FetchFromMasterSigner(ChildNumber), SelectEnterSafetyNetToken, SelectEnterCosignerToken, SelectBorderWalletSafetyNet, @@ -181,7 +181,8 @@ pub struct SelectKeySource { accounts: HashMap, /// Informations about the actual spending path. actual_path: PathData, - hot_signer: Arc>, + master_signer: Arc>, + developer_mode: bool, /// The currently selected key. selected_key: SelectedKey, step: Step, @@ -210,7 +211,8 @@ impl SelectKeySource { actual_path: PathData, keys: HashMap, Key)>, accounts: HashMap, - hot_signer: Arc>, + master_signer: Arc>, + developer_mode: bool, ) -> Self { Self { network, @@ -218,7 +220,8 @@ impl SelectKeySource { keys, accounts, actual_path, - hot_signer, + master_signer, + developer_mode, selected_key: SelectedKey::None, step: Step::Select, focus: Focus::None, @@ -487,7 +490,7 @@ impl SelectKeySource { Task::none() } fn on_select_generate_hot_key(&mut self) -> Task { - self.focus = Focus::GenerateHotKey; + self.focus = Focus::GenerateMasterKey; let _ = self.on_next(); self.processing = true; Task::done(Self::route(SelectKeySourceMessage::Account( @@ -496,14 +499,14 @@ impl SelectKeySource { } fn on_fetch_from_hotsigner(&mut self, account: ChildNumber) -> Task { self.processing = false; - let fingerprint = self.hot_signer.lock().unwrap().fingerprint(); + let fingerprint = self.master_signer.lock().unwrap().fingerprint(); if self.keys.contains_key(&fingerprint) { self.selected_key = SelectedKey::Existing(fingerprint); return Task::none(); } - self.form_alias.value = "Hot Signer".to_string(); + self.form_alias.value = "Master Signer".to_string(); self.form_alias.valid = true; let derivation_path = derivation_path(self.network, account); @@ -511,7 +514,7 @@ impl SelectKeySource { "[{}/{}]{}", fingerprint, derivation_path.to_string().trim_start_matches("m/"), - self.hot_signer + self.master_signer .lock() .expect("poisoned") .get_extended_pubkey(&derivation_path) @@ -519,7 +522,7 @@ impl SelectKeySource { let key = DescriptorPublicKey::from_str(&key_str).expect("always ok"); let key = Key { - source: KeySource::HotSigner, + source: KeySource::MasterSigner, name: self.form_alias.value.clone(), fingerprint, key, @@ -797,7 +800,7 @@ impl SelectKeySource { Focus::Device(fg) => Task::done(Self::route(SelectKeySourceMessage::FetchFromDevice( fg, index, ))), - Focus::GenerateHotKey => self.on_fetch_from_hotsigner(index), + Focus::GenerateMasterKey => self.on_fetch_from_hotsigner(index), _ => Task::none(), } } @@ -884,8 +887,8 @@ impl SelectKeySource { Focus::Device(fg) => Task::done(Self::route(SelectKeySourceMessage::FetchFromDevice( fg, account, ))), - Focus::GenerateHotKey => Task::done(Self::route( - SelectKeySourceMessage::FetchFromHotSigner(account), + Focus::GenerateMasterKey => Task::done(Self::route( + SelectKeySourceMessage::FetchFromMasterSigner(account), )), _ => Task::none(), } @@ -939,7 +942,7 @@ impl SelectKeySource { }; let fingerprint = match self.focus { Focus::Key(fg) | Focus::Device(fg) => fg, - Focus::GenerateHotKey => self.hot_signer.lock().expect("poisoned").fingerprint(), + Focus::GenerateMasterKey => self.master_signer.lock().expect("poisoned").fingerprint(), _ => match &self.selected_key { SelectedKey::Existing(fg) => *fg, SelectedKey::New(key) => key.fingerprint, @@ -1060,9 +1063,11 @@ impl SelectKeySource { || Self::route(SelectKeySourceMessage::Collapse(false)), ); - let hot_signer_fg = self.hot_signer.lock().expect("poisoned").fingerprint(); - let hot_signer = (!self.keys.contains_key(&hot_signer_fg) && safety_net_token.is_none()) - .then_some(self.widget_generate_hot_key()); + let master_signer_fg = self.master_signer.lock().expect("poisoned").fingerprint(); + let master_signer = (self.developer_mode + && !self.keys.contains_key(&master_signer_fg) + && safety_net_token.is_none()) + .then_some(self.widget_generate_hot_key()); let load_key = safety_net_token.is_none().then_some(self.widget_load_key()); @@ -1074,7 +1079,7 @@ impl SelectKeySource { col = col .push(load_key) .push(paste_xpub) - .push(hot_signer) + .push(master_signer) .push(cosigner_token) .push(border_wallet) .push(safety_net_token); @@ -1152,7 +1157,7 @@ impl SelectKeySource { let (source, alias, fg, available) = key; let icon = match source { KeySource::Device(..) => icon::usb_drive_icon(), - KeySource::HotSigner => icon::round_key_icon().color(color::RED), + KeySource::MasterSigner => icon::round_key_icon().color(color::RED), KeySource::Manual => icon::round_key_icon(), KeySource::Token(..) => icon::hdd_icon(), KeySource::BorderWallet => icon::round_key_icon(), @@ -1190,12 +1195,17 @@ impl SelectKeySource { ) } fn widget_generate_hot_key(&self) -> Element { + let subtitle = if self.developer_mode { + "⚠ Dev mode: derived from master seed — not for production use" + } else { + "We recommend to use this option only for test purposes" + }; modal::button_entry( Some(icon::round_key_icon().color(color::RED)), "Generate hot key stored on this computer", - Some("We recommend to use this option only for test purposes"), + Some(subtitle), None, - Some(|| Self::route(SelectKeySourceMessage::SelectGenerateHotKey)), + Some(|| Self::route(SelectKeySourceMessage::SelectGenerateMasterKey)), ) } fn widget_paste_xpub(&self) -> Element { @@ -1292,8 +1302,15 @@ impl super::DescriptorEditModal for SelectKeySource { SelectKeySourceMessage::SelectEnterXpub => self.on_select_enter_xpub(), SelectKeySourceMessage::PasteXpub => self.on_paste_xpub(), SelectKeySourceMessage::Xpub(xpub) => self.on_update_xpub(xpub), - SelectKeySourceMessage::SelectGenerateHotKey => self.on_select_generate_hot_key(), - SelectKeySourceMessage::FetchFromHotSigner(account) => { + SelectKeySourceMessage::SelectGenerateMasterKey => { + if self.developer_mode { + self.on_select_generate_hot_key() + } else { + tracing::warn!("hot-signer message received in production mode — ignoring"); + Task::none() + } + } + SelectKeySourceMessage::FetchFromMasterSigner(account) => { self.on_fetch_from_hotsigner(account) } SelectKeySourceMessage::SelectEnterCosignerToken => { diff --git a/coincube-gui/src/installer/step/descriptor/editor/mod.rs b/coincube-gui/src/installer/step/descriptor/editor/mod.rs index 0041c847d..53c5e2efc 100644 --- a/coincube-gui/src/installer/step/descriptor/editor/mod.rs +++ b/coincube-gui/src/installer/step/descriptor/editor/mod.rs @@ -57,6 +57,7 @@ pub struct DefineDescriptor { modal: Option>, signer: Arc>, + developer_mode: bool, keys: HashMap, paths: Vec, @@ -67,13 +68,14 @@ pub struct DefineDescriptor { } impl DefineDescriptor { - pub fn new(network: Network, signer: Arc>) -> Self { + pub fn new(network: Network, signer: Arc>, developer_mode: bool) -> Self { Self { network, use_taproot: false, modal: None, signer, + developer_mode, error: None, keys: HashMap::new(), descriptor_template: DescriptorTemplate::default(), @@ -213,6 +215,7 @@ impl DefineDescriptor { keys, self.accounts.clone(), self.signer.clone(), + self.developer_mode, ) } } @@ -247,8 +250,12 @@ impl Step for DefineDescriptor { Message::DefineDescriptor(message::DefineDescriptor::OpenBorderWalletWizard( coordinates, )) => { - let modal = - border_wallet_wizard::BorderWalletWizard::new(self.network, coordinates); + let modal = border_wallet_wizard::BorderWalletWizard::new( + self.network, + coordinates, + self.signer.clone(), + false, // Default: use master-derived grid phrase + ); self.modal = Some(Box::new(modal)); } Message::DefineDescriptor(message::DefineDescriptor::KeysEdited(coordinates, key)) => { @@ -817,7 +824,7 @@ mod tests { } #[tokio::test] - async fn test_define_descriptor_use_hotkey() { + async fn test_define_descriptor_use_masterkey() { let mut ctx = Context::new( Network::Signet, CoincubeDirectory::new(PathBuf::from_str("/").unwrap()), @@ -825,7 +832,8 @@ mod tests { ); let sandbox: Sandbox = Sandbox::new(DefineDescriptor::new( Network::Signet, - Arc::new(Mutex::new(Signer::generate(Network::Bitcoin).unwrap())), + Arc::new(Mutex::new(Signer::generate(Network::Signet).unwrap())), + true, )); sandbox.load(&ctx).await; @@ -839,12 +847,12 @@ mod tests { sandbox.check(|step| assert!(step.modal.is_some())); sandbox .update(SelectKeySource::route( - key::SelectKeySourceMessage::SelectGenerateHotKey, + key::SelectKeySourceMessage::SelectGenerateMasterKey, )) .await; sandbox .update(SelectKeySource::route(key::SelectKeySourceMessage::Alias( - "hot_signer_key".to_string(), + "master_signer_key".to_string(), ))) .await; sandbox @@ -908,6 +916,7 @@ mod tests { let sandbox: Sandbox = Sandbox::new(DefineDescriptor::new( Network::Testnet, Arc::new(Mutex::new(Signer::generate(Network::Testnet).unwrap())), + true, // developer_mode=true: test exercises the hot-signer path )); sandbox.load(&ctx).await; @@ -962,7 +971,7 @@ mod tests { assert!(ctx.hw_is_used); }); - // Now edit primary key to use hot signer instead of Specter device + // Now edit primary key to use master signer instead of Specter device sandbox .update(Message::DefineDescriptor(message::DefineDescriptor::Path( 0, @@ -972,12 +981,12 @@ mod tests { sandbox.check(|step| assert!(step.modal.is_some())); sandbox .update(SelectKeySource::route( - key::SelectKeySourceMessage::SelectGenerateHotKey, + key::SelectKeySourceMessage::SelectGenerateMasterKey, )) .await; sandbox .update(SelectKeySource::route(key::SelectKeySourceMessage::Alias( - "hot signer key".to_string(), + "master signer key".to_string(), ))) .await; sandbox diff --git a/coincube-gui/src/installer/step/mnemonic.rs b/coincube-gui/src/installer/step/mnemonic.rs index 1fcc78fa9..c6e356946 100644 --- a/coincube-gui/src/installer/step/mnemonic.rs +++ b/coincube-gui/src/installer/step/mnemonic.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use std::sync::{Arc, Mutex}; -use coincube_core::{bip39, signer::HotSigner}; +use coincube_core::{bip39, signer::MasterSigner}; use iced::Task; use coincube_ui::widget::Element; @@ -136,7 +136,7 @@ impl Step for RecoverMnemonic { .filter_map(|(s, valid)| if *valid { Some(s.clone()) } else { None }) .collect(); - let seed = match HotSigner::from_str(ctx.bitcoin_config.network, &words.join(" ")) { + let seed = match MasterSigner::from_str(ctx.bitcoin_config.network, &words.join(" ")) { Ok(seed) => seed, Err(e) => { self.error = Some(e.to_string()); diff --git a/coincube-gui/src/installer/step/share_xpubs.rs b/coincube-gui/src/installer/step/share_xpubs.rs index d39ca98bd..0721bdb83 100644 --- a/coincube-gui/src/installer/step/share_xpubs.rs +++ b/coincube-gui/src/installer/step/share_xpubs.rs @@ -131,7 +131,7 @@ impl Step for ShareXpubs { return modal.update(msg); } } - Message::UseHotSigner => { + Message::UseMasterSigner => { self.xpubs_signer.select(self.network); } Message::UserActionDone(done) => { diff --git a/coincube-gui/src/installer/view/mod.rs b/coincube-gui/src/installer/view/mod.rs index 11198a131..45a09c36b 100644 --- a/coincube-gui/src/installer/view/mod.rs +++ b/coincube-gui/src/installer/view/mod.rs @@ -400,7 +400,7 @@ pub fn signer_xpubs<'a>( .width(Length::Fill), ), ) - .on_press(Message::UseHotSigner) + .on_press(Message::UseMasterSigner) .padding(10) .style(theme::button::secondary) .width(Length::Fill), diff --git a/coincube-gui/src/launcher.rs b/coincube-gui/src/launcher.rs index 429e9fa64..7ebb0a1b7 100644 --- a/coincube-gui/src/launcher.rs +++ b/coincube-gui/src/launcher.rs @@ -14,10 +14,14 @@ use coincube_ui::{ use coincubed::config::ConfigError; use tokio::runtime::Handle; +use crate::feature_flags; use crate::pin_input; use crate::services::coincube::{ CubeLimitsResponse, CubeResponse, RegisterCubeRequest, UpdateCubeRequest, }; +#[cfg(not(target_os = "macos"))] +use crate::services::passkey::CeremonyMode; +use crate::services::passkey::{self as passkey_svc, CeremonyOutcome, PasskeyCeremony}; use crate::{ app::{ self, @@ -37,7 +41,7 @@ use crate::{ login::{connect_with_credentials, BackendState}, }, }; -use coincube_core::signer::HotSigner; +use coincube_core::signer::{MasterSigner, MASTER_SEED_LABEL}; const NETWORKS: [Network; 5] = [ Network::Bitcoin, @@ -105,7 +109,6 @@ pub struct Launcher { create_cube_name: coincube_ui::component::form::Value, create_cube_pin: pin_input::PinInput, create_cube_pin_confirm: pin_input::PinInput, - recover_liquid_wallet: bool, creating_cube: bool, /// UUID pre-generated on the first creation attempt and reused on retries /// so that each logical cube has a stable client-side identifier. @@ -123,6 +126,13 @@ pub struct Launcher { pub active_section: LauncherSection, /// Current theme mode (dark/light) — used for theme-aware rendering pub theme_mode: coincube_ui::theme::palette::ThemeMode, + /// Whether the user has chosen to create a passkey-derived Cube (no PIN). + passkey_mode: bool, + /// Active passkey ceremony (webview open, awaiting IPC result). + passkey_ceremony: Option, + /// Active native macOS passkey ceremony (uses AuthenticationServices). + #[cfg(target_os = "macos")] + native_passkey_ceremony: Option, /// Whether a Connect session exists in the OS keyring (cached to avoid /// synchronous keyring I/O on every render). has_stored_session: bool, @@ -172,7 +182,6 @@ impl Launcher { create_cube_name: coincube_ui::component::form::Value::default(), create_cube_pin: pin_input::PinInput::new(), create_cube_pin_confirm: pin_input::PinInput::new(), - recover_liquid_wallet: false, creating_cube: false, pending_cube_id: None, recovery_words: Default::default(), @@ -185,6 +194,13 @@ impl Launcher { connect_expanded: false, active_section: LauncherSection::Cubes, theme_mode: GlobalSettings::load_theme_mode(&GlobalSettings::path(&datadir_path)), + // Default to the feature flag value. When the passkey feature + // is disabled (the common case pre-launch), this is always + // `false`, forcing the PIN flow. + passkey_mode: feature_flags::PASSKEY_ENABLED, + passkey_ceremony: None, + #[cfg(target_os = "macos")] + native_passkey_ceremony: None, has_stored_session: ConnectAccountPanel::has_stored_session(), server_cube_limit: None, rename_cube_modal: None, @@ -233,7 +249,32 @@ impl Launcher { pub fn stop(&mut self) {} + /// Set a top-level error message shown on the launcher screen. + /// Used by outer state machines (e.g. `gui::tab`) to surface issues + /// they detect while handling launcher-originated messages. + pub fn set_error(&mut self, msg: impl Into) { + self.error = Some(msg.into()); + } + pub fn subscription(&self) -> Subscription { + if let Some(ceremony) = &self.passkey_ceremony { + if ceremony.active_webview.is_some() { + return ceremony + .webview_manager + .subscription(std::time::Duration::from_millis(25)) + .map(Message::PasskeyWebviewUpdate); + } + } + + // Native macOS passkey ceremony — poll the channel periodically. + #[cfg(target_os = "macos")] + { + if self.native_passkey_ceremony.is_some() { + return iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::NativePasskeyTick); + } + } + Subscription::none() } @@ -273,6 +314,8 @@ impl Launcher { self.create_cube_name = coincube_ui::component::form::Value::default(); self.create_cube_pin = pin_input::PinInput::new(); self.create_cube_pin_confirm = pin_input::PinInput::new(); + // Reset to the feature flag default (false when disabled). + self.passkey_mode = feature_flags::PASSKEY_ENABLED; // Clear recovery words when exiting create cube flow for word in &mut self.recovery_words { word.clear(); @@ -301,6 +344,11 @@ impl Launcher { .update(msg) .map(|m| Message::View(ViewMessage::PinConfirmInput(m))) } + Message::View(ViewMessage::TogglePasskeyMode(enabled)) => { + self.passkey_mode = enabled; + self.error = None; + Task::none() + } Message::View(ViewMessage::CreateCube) => { if self.creating_cube { return Task::none(); @@ -326,97 +374,144 @@ impl Launcher { return Task::none(); } - // Validate PIN (always required) - if !self.create_cube_pin.is_complete() { - self.error = Some("Please enter all 4 PIN digits".to_string()); - return Task::none(); - } - if !self.create_cube_pin_confirm.is_complete() { - self.error = Some("Please confirm all 4 PIN digits".to_string()); - return Task::none(); - } - if self.create_cube_pin.value() != self.create_cube_pin_confirm.value() { - self.error = Some("PIN codes do not match".to_string()); - return Task::none(); + // Defensive guard: even if `self.passkey_mode` somehow became + // true while the feature is disabled (stale state, manual + // toggle before a hot-reload, etc.), always fall through to + // the PIN flow when the compile-time flag is off. + let passkey_mode = self.passkey_mode && feature_flags::PASSKEY_ENABLED; + + if !passkey_mode { + // PIN-based flow: validate PIN + if !self.create_cube_pin.is_complete() { + self.error = Some("Please enter all 4 PIN digits".to_string()); + return Task::none(); + } + if !self.create_cube_pin_confirm.is_complete() { + self.error = Some("Please confirm all 4 PIN digits".to_string()); + return Task::none(); + } + if self.create_cube_pin.value() != self.create_cube_pin_confirm.value() { + self.error = Some("PIN codes do not match".to_string()); + return Task::none(); + } } self.creating_cube = true; let network = self.network; let cube_name = self.create_cube_name.value.trim().to_string(); - let pin = self.create_cube_pin.value(); + let pin = if passkey_mode { + String::new() + } else { + self.create_cube_pin.value() + }; let datadir_path = self.datadir_path.clone(); // Pre-generate the UUID before the async task so that retries // reuse the same identifier (idempotent creation). let cube_id = *self.pending_cube_id.get_or_insert_with(uuid::Uuid::new_v4); - let without_recovery = Task::perform( - async move { - // Generate Liquid wallet HotSigner - let liquid_signer = HotSigner::generate(network).map_err(|e| { - format!("Failed to generate Liquid wallet signer: {}", e) - })?; - - // Create secp context for fingerprint calculation - let secp = coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); - let liquid_fingerprint = liquid_signer.fingerprint(&secp); - - // Store Liquid wallet mnemonic (encrypted with PIN if provided) - let network_dir = datadir_path.network_directory(network); - network_dir - .init() - .map_err(|e| format!("Failed to create network directory: {}", e))?; - - // Use a timestamp for the Liquid wallet storage - let timestamp = chrono::Utc::now().timestamp(); - let liquid_checksum = format!("liquid_{}", timestamp); - - // Store Liquid wallet mnemonic encrypted with PIN (always required) - liquid_signer - .store_encrypted( - datadir_path.path(), - network, - &secp, - Some((liquid_checksum, timestamp)), - Some(&pin), - ) - .map_err(|e| { - format!("Failed to store Liquid wallet mnemonic: {}", e) + let without_recovery = if passkey_mode { + // Passkey-based Cube creation. + // On macOS: use the native AuthenticationServices framework + // (WKWebView doesn't have the entitlement to call WebAuthn). + // On other platforms: fall back to the embedded webview ceremony. + #[cfg(target_os = "macos")] + { + let user_id_bytes = cube_id.as_bytes().to_vec(); + match crate::services::passkey::macos::NativePasskeyCeremony::register( + passkey_svc::RP_ID, + &user_id_bytes, + &cube_name, + ) { + Ok(ceremony) => { + self.native_passkey_ceremony = Some(ceremony); + Task::none() + } + Err(e) => { + self.creating_cube = false; + self.error = + Some(format!("Failed to start passkey ceremony: {}", e)); + Task::none() + } + } + } + #[cfg(not(target_os = "macos"))] + { + let user_id = cube_id.to_string(); + let ceremony = PasskeyCeremony::new(CeremonyMode::Register { + user_id, + user_name: cube_name, + }); + self.passkey_ceremony = Some(ceremony); + // Extract the window ID so we can attach the webview + iced_wry::extract_window_id(None).map(Message::PasskeyWindowId) + } + } else { + // PIN-based Cube creation (existing flow) + Task::perform( + async move { + // Generate MasterSigner + let master_signer = MasterSigner::generate(network).map_err(|e| { + format!("Failed to generate master seed signer: {}", e) })?; - tracing::info!("Liquid wallet signer created and stored (encrypted with PIN) with fingerprint: {}", liquid_fingerprint); - - // Build Cube settings using the pre-generated, stable UUID. - let cube = CubeSettings::new_with_id(cube_id, cube_name, network) - .with_liquid_signer(liquid_fingerprint) - .with_pin(&pin) - .map_err(|e| format!("Failed to hash PIN: {}", e))?; - - // Save Cube settings to settings file. - // Idempotency: if a cube with this UUID was already persisted - // (e.g. a previous attempt succeeded but the message was lost), - // skip the insert and return the existing entry. - settings::update_settings_file(&network_dir, |mut settings| { - if settings.cubes.iter().any(|c| c.id == cube.id) { - return Some(settings); - } - settings.cubes.push(cube.clone()); - Some(settings) - }) - .await - .map(|_| cube) - .map_err(|e| e.to_string()) - }, - Message::CubeCreated, - ); + // Create secp context for fingerprint calculation + let secp = + coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); + let master_fingerprint = master_signer.fingerprint(&secp); - if self.recover_liquid_wallet { - // Enter recovery flow - show recovery input UI - self.creating_cube = false; - Task::done(Message::StartRecovery) - } else { - without_recovery - } + // Store master seed mnemonic (encrypted with PIN) + let network_dir = datadir_path.network_directory(network); + network_dir.init().map_err(|e| { + format!("Failed to create network directory: {}", e) + })?; + + // Use a timestamp for the master seed storage + let timestamp = chrono::Utc::now().timestamp(); + let master_checksum = format!("{}{}", MASTER_SEED_LABEL, timestamp); + + // Store master seed mnemonic encrypted with PIN + master_signer + .store_encrypted( + datadir_path.path(), + network, + &secp, + Some((master_checksum, timestamp)), + Some(&pin), + ) + .map_err(|e| { + format!("Failed to store master seed mnemonic: {}", e) + })?; + + tracing::info!( + "Master signer created and stored (encrypted with PIN) \ + with fingerprint: {}", + master_fingerprint + ); + + // Build Cube settings + let cube = CubeSettings::new_with_id(cube_id, cube_name, network) + .with_master_signer(master_fingerprint) + .with_pin(&pin) + .map_err(|e| format!("Failed to hash PIN: {}", e))?; + + // Save Cube settings + settings::update_settings_file(&network_dir, |mut settings| { + if settings.cubes.iter().any(|c| c.id == cube.id) { + return Some(settings); + } + settings.cubes.push(cube.clone()); + Some(settings) + }) + .await + .map(|_| cube) + .map_err(|e| e.to_string()) + }, + Message::CubeCreated, + ) + }; + + without_recovery } Message::StartRecovery => { self.state = State::RecoveryInput; @@ -481,6 +576,31 @@ impl Launcher { } } } + // --- Passkey ceremony flow --- + Message::PasskeyWindowId(window_id) => { + if let Some(ceremony) = &mut self.passkey_ceremony { + if !ceremony.create_webview(window_id) { + self.creating_cube = false; + self.passkey_ceremony = None; + self.error = Some( + "Failed to open passkey webview. Check your system's WebView support." + .to_string(), + ); + } + } + Task::none() + } + Message::PasskeyWebviewUpdate(msg) => { + if let Some(ceremony) = &mut self.passkey_ceremony { + ceremony.webview_manager.update(msg); + + // Poll for IPC result + if let Some(result) = ceremony.try_recv_result() { + return Task::done(Message::PasskeyCeremonyResult(result)); + } + } + Task::none() + } Message::CubeRemoteRegistered { cube_id, network, @@ -522,6 +642,186 @@ impl Launcher { } Task::none() } + Message::PasskeyCeremonyResult(result) => { + // Close the ceremony webview + if let Some(mut ceremony) = self.passkey_ceremony.take() { + ceremony.close(); + } + + match result { + Ok(CeremonyOutcome::Registered(registration)) => { + // Derive master signer from PRF output + let network = self.network; + let datadir_path = self.datadir_path.clone(); + let cube_id = *self.pending_cube_id.get_or_insert_with(uuid::Uuid::new_v4); + let cube_name = self.create_cube_name.value.trim().to_string(); + let credential_id = registration.credential_id.clone(); + let prf_output = registration.prf_output; + + Task::perform( + async move { + let master_signer = + MasterSigner::from_prf_output(network, &prf_output).map_err( + |e| format!("Failed to derive master signer: {}", e), + )?; + + let secp = + coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); + let master_fingerprint = master_signer.fingerprint(&secp); + + let network_dir = datadir_path.network_directory(network); + network_dir.init().map_err(|e| { + format!("Failed to create network directory: {}", e) + })?; + + // Passkey Cube: no encrypted seed file, no PIN. + let passkey_metadata = settings::PasskeyMetadata { + credential_id, + rp_id: passkey_svc::RP_ID.to_string(), + created_at: chrono::Utc::now().timestamp(), + label: None, + }; + + let cube = CubeSettings::new_with_id(cube_id, cube_name, network) + .with_master_signer(master_fingerprint) + .with_passkey(passkey_metadata); + + tracing::info!( + "Passkey Cube created with fingerprint: {} (no seed on disk)", + master_fingerprint + ); + + settings::update_settings_file(&network_dir, |mut settings| { + if settings.cubes.iter().any(|c| c.id == cube.id) { + return Some(settings); + } + settings.cubes.push(cube.clone()); + Some(settings) + }) + .await + .map(|_| cube) + .map_err(|e| e.to_string()) + }, + Message::CubeCreated, + ) + } + Ok(CeremonyOutcome::Authenticated(_auth)) => { + // Authentication during creation shouldn't happen, + // but handle gracefully. + self.creating_cube = false; + self.error = Some( + "Unexpected authentication response during registration.".to_string(), + ); + Task::none() + } + Err(e) => { + self.creating_cube = false; + self.error = Some(e.to_string()); + Task::none() + } + } + } + Message::CancelPasskeyCeremony => { + if let Some(mut ceremony) = self.passkey_ceremony.take() { + ceremony.close(); + } + #[cfg(target_os = "macos")] + { + if let Some(ceremony) = self.native_passkey_ceremony.take() { + ceremony.cancel(); + } + } + self.creating_cube = false; + Task::none() + } + #[cfg(target_os = "macos")] + Message::NativePasskeyTick => { + use crate::services::passkey::macos::NativeOutcome; + let outcome = self + .native_passkey_ceremony + .as_ref() + .and_then(|c| c.try_recv()); + let Some(outcome) = outcome else { + return Task::none(); + }; + // Drop the ceremony now that we have a result. + self.native_passkey_ceremony = None; + + match outcome { + NativeOutcome::Registered { + credential_id, + prf_output, + } => { + let network = self.network; + let datadir_path = self.datadir_path.clone(); + let cube_id = *self.pending_cube_id.get_or_insert_with(uuid::Uuid::new_v4); + let cube_name = self.create_cube_name.value.trim().to_string(); + let credential_id_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &credential_id, + ); + Task::perform( + async move { + let master_signer = + MasterSigner::from_prf_output(network, &prf_output).map_err( + |e| format!("Failed to derive master signer: {}", e), + )?; + + let secp = + coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); + let master_fingerprint = master_signer.fingerprint(&secp); + + let network_dir = datadir_path.network_directory(network); + network_dir.init().map_err(|e| { + format!("Failed to create network directory: {}", e) + })?; + + let passkey_metadata = settings::PasskeyMetadata { + credential_id: credential_id_b64, + rp_id: passkey_svc::RP_ID.to_string(), + created_at: chrono::Utc::now().timestamp(), + label: None, + }; + + let cube = CubeSettings::new_with_id(cube_id, cube_name, network) + .with_master_signer(master_fingerprint) + .with_passkey(passkey_metadata); + + tracing::info!( + "Passkey Cube created (native macOS) with fingerprint: {}", + master_fingerprint + ); + + settings::update_settings_file(&network_dir, |mut settings| { + if settings.cubes.iter().any(|c| c.id == cube.id) { + return Some(settings); + } + settings.cubes.push(cube.clone()); + Some(settings) + }) + .await + .map(|_| cube) + .map_err(|e| e.to_string()) + }, + Message::CubeCreated, + ) + } + NativeOutcome::Authenticated { .. } => { + self.creating_cube = false; + self.error = Some( + "Unexpected authentication response during registration.".to_string(), + ); + Task::none() + } + NativeOutcome::Error(e) => { + self.creating_cube = false; + self.error = Some(e); + Task::none() + } + } + } + #[cfg(not(target_os = "macos"))] + Message::NativePasskeyTick => Task::none(), Message::RemoteCubesLoaded(result) => { match result { Ok(remote_only) => { @@ -873,10 +1173,7 @@ impl Launcher { Task::none() } } - Message::View(ViewMessage::ToggleRecoveryCheckBox) => { - self.recover_liquid_wallet = !self.recover_liquid_wallet; - Task::none() - } + Message::View(ViewMessage::ToggleRecoveryCheckBox) => Task::none(), Message::View(ViewMessage::RecoveryWordInput { index, word }) => { if index < 12 { let normalized = word @@ -952,8 +1249,8 @@ impl Launcher { Task::perform( async move { - // Restore Liquid wallet HotSigner from mnemonic - let liquid_signer = HotSigner::from_mnemonic(network, mnemonic) + // Restore Liquid wallet MasterSigner from mnemonic + let master_signer = MasterSigner::from_mnemonic(network, mnemonic) .map_err(|e| { format!("Failed to restore from mnemonic: {}", e) })?; @@ -961,36 +1258,36 @@ impl Launcher { // Create secp context for fingerprint calculation let secp = coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); - let liquid_fingerprint = liquid_signer.fingerprint(&secp); + let master_fingerprint = master_signer.fingerprint(&secp); - // Store Liquid wallet mnemonic (encrypted with PIN if provided) + // Store master seed mnemonic (encrypted with PIN) let network_dir = datadir_path.network_directory(network); network_dir.init().map_err(|e| { format!("Failed to create network directory: {}", e) })?; - // Use a timestamp for the Liquid wallet storage + // Use a timestamp for the master seed storage let timestamp = chrono::Utc::now().timestamp(); - let liquid_checksum = format!("liquid_{}", timestamp); + let master_checksum = format!("{}{}", MASTER_SEED_LABEL, timestamp); - // Store Liquid wallet mnemonic encrypted with PIN (always required) - liquid_signer + // Store master seed mnemonic encrypted with PIN (always required) + master_signer .store_encrypted( datadir_path.path(), network, &secp, - Some((liquid_checksum, timestamp)), + Some((master_checksum, timestamp)), Some(&pin), ) .map_err(|e| { - format!("Failed to store Liquid wallet mnemonic: {}", e) + format!("Failed to store master seed mnemonic: {}", e) })?; - tracing::info!("Liquid wallet signer created and stored (encrypted with PIN) with fingerprint: {}", liquid_fingerprint); + tracing::info!("Master signer created and stored (encrypted with PIN) with fingerprint: {}", master_fingerprint); // Build Cube settings using the pre-generated, stable UUID. let cube = CubeSettings::new_with_id(cube_id, cube_name, network) - .with_liquid_signer(liquid_fingerprint) + .with_master_signer(master_fingerprint) .with_pin(&pin) .map_err(|e| format!("Failed to hash PIN: {}", e))?; @@ -1443,7 +1740,7 @@ impl Launcher { &self.create_cube_pin_confirm, &self.error, self.creating_cube, - self.recover_liquid_wallet, + self.passkey_mode, ) } else { let current_net_str = @@ -1462,8 +1759,8 @@ impl Launcher { col = col.push(remote_cube_list_item(rc)); } let total_count = self.total_cube_count(); - let at_limit = - total_count >= self.cube_limit(); + let at_limit = cubes.len() >= self.account_tier.cube_limit() + && matches!(self.network, Network::Bitcoin); if at_limit { col = col.push( Column::new() @@ -1543,7 +1840,7 @@ impl Launcher { &self.create_cube_pin_confirm, &self.error, self.creating_cube, - self.recover_liquid_wallet, + self.passkey_mode, )); } col.into() @@ -1605,6 +1902,71 @@ impl Launcher { } else { layout }; + // If passkey ceremony webview is active, overlay it on top + let layout = if let Some(ceremony) = &self.passkey_ceremony { + if let Some(active) = &ceremony.active_webview { + let cancel_btn = button::secondary(None, "Cancel") + .on_press(Message::CancelPasskeyCeremony) + .width(Length::Fixed(150.0)); + + let webview_modal = Container::new( + Column::new() + .spacing(15) + .align_x(Alignment::Center) + .push(h4_bold("Passkey Registration")) + .push( + p1_regular("Complete the passkey setup in the window below.") + .style(theme::text::secondary), + ) + .push(active.view(Length::Fixed(500.0), Length::Fixed(400.0))) + .push(cancel_btn) + .width(550), + ) + .padding(20) + .style(theme::card::modal); + + Modal::new(Container::new(layout).height(Length::Fill), webview_modal) + .on_blur(Some(Message::CancelPasskeyCeremony)) + .into() + } else { + layout + } + } else { + layout + }; + + // Native macOS passkey ceremony status modal + #[cfg(target_os = "macos")] + let layout = if self.native_passkey_ceremony.is_some() { + let cancel_btn = button::secondary(None, "Cancel") + .on_press(Message::CancelPasskeyCeremony) + .width(Length::Fixed(150.0)); + + let status_modal = Container::new( + Column::new() + .spacing(20) + .align_x(Alignment::Center) + .push(h4_bold("Passkey Registration")) + .push( + p1_regular( + "Authenticate with Touch ID to create your passkey.\n\ + Look for the system prompt.", + ) + .style(theme::text::secondary), + ) + .push(cancel_btn) + .width(450), + ) + .padding(30) + .style(theme::card::modal); + + Modal::new(Container::new(layout).height(Length::Fill), status_modal) + .on_blur(Some(Message::CancelPasskeyCeremony)) + .into() + } else { + layout + }; + if let Some(modal) = &self.delete_cube_modal { Modal::new(Container::new(layout).height(Length::Fill), modal.view()) .on_blur(Some(Message::View(ViewMessage::DeleteCube( @@ -1813,7 +2175,7 @@ fn create_cube_form<'a>( pin_confirm: &'a pin_input::PinInput, error: &'a Option, creating_cube: bool, - recover_liquid_wallet: bool, + passkey_mode: bool, ) -> Element<'a, ViewMessage> { use coincube_ui::component::form; use std::time::Duration; @@ -1830,6 +2192,22 @@ fn create_cube_form<'a>( .style(theme::text::secondary), ); + // Passkey toggle — hidden entirely when the passkey feature is disabled + // via the COINCUBE_ENABLE_PASSKEY env var. The surrounding PIN flow + // remains fully functional in that case. + if feature_flags::PASSKEY_ENABLED { + column = column.push( + Toggler::new(passkey_mode) + .label(if cfg!(target_os = "macos") { + "Use Passkey (Touch ID)" + } else if cfg!(target_os = "windows") { + "Use Passkey (Windows Hello)" + } else { + "Use Passkey (Security Key)" + }) + .on_toggle(ViewMessage::TogglePasskeyMode), + ); + } column = column.push( Container::new( form::Form::new("Cube Name", cube_name, ViewMessage::CubeNameEdited) @@ -1843,15 +2221,33 @@ fn create_cube_form<'a>( // PIN setup section (always required) column = column.push(Space::new().height(Length::Fixed(10.0))); - let pin_label = p1_regular("Enter PIN:").style(theme::text::secondary); - column = column.push(pin_label); - column = column.push(pin.view().map(ViewMessage::PinInput)); + if passkey_mode { + // Passkey mode: no PIN needed — biometric auth replaces it + let description = if cfg!(target_os = "macos") { + "Your Cube will be secured with a passkey. No PIN is needed \u{2014} \ + you'll use Touch ID to unlock it." + } else if cfg!(target_os = "windows") { + "Your Cube will be secured with a passkey. No PIN is needed \u{2014} \ + you'll use Windows Hello to unlock it." + } else { + "Your Cube will be secured with a passkey. No PIN is needed \u{2014} \ + you'll use a FIDO2 security key to unlock it." + }; + column = column.push(p1_regular(description).style(theme::text::secondary)); + } else { + // PIN setup section + column = column.push(Space::new().height(Length::Fixed(10.0))); + + let pin_label = p1_regular("Enter PIN:").style(theme::text::secondary); + column = column.push(pin_label); + column = column.push(pin.view().map(ViewMessage::PinInput)); - column = column.push(Space::new().height(Length::Fixed(20.0))); + column = column.push(Space::new().height(Length::Fixed(20.0))); - let pin_confirm_label = p1_regular("Confirm PIN:").style(theme::text::secondary); - column = column.push(pin_confirm_label); - column = column.push(pin_confirm.view().map(ViewMessage::PinConfirmInput)); + let pin_confirm_label = p1_regular("Confirm PIN:").style(theme::text::secondary); + column = column.push(pin_confirm_label); + column = column.push(pin_confirm.view().map(ViewMessage::PinConfirmInput)); + } column = column.push(Space::new().height(Length::Fixed(10.0))); @@ -1860,21 +2256,17 @@ fn create_cube_form<'a>( column = column.push(p1_regular(err).style(theme::text::error)); } - column = column.push( - CheckBox::new(recover_liquid_wallet) - .label("Recover Liquid Wallet") - .on_toggle(|_| ViewMessage::ToggleRecoveryCheckBox) - .size(20), - ); - column = column.push(Space::new().height(Length::Fixed(10.0))); // Determine if button should be enabled - // PIN is always required, so all PIN fields must be filled - let can_create = !creating_cube - && cube_name.valid - && !cube_name.value.trim().is_empty() - && pin.is_complete() - && pin_confirm.is_complete(); + let can_create = if passkey_mode { + !creating_cube && cube_name.valid && !cube_name.value.trim().is_empty() + } else { + !creating_cube + && cube_name.valid + && !cube_name.value.trim().is_empty() + && pin.is_complete() + && pin_confirm.is_complete() + }; let submit_button = if creating_cube { iced::widget::button( @@ -2230,6 +2622,16 @@ pub enum Message { ), StartRecovery, CubeCreated(Result), + /// Window ID extracted for passkey webview. + PasskeyWindowId(iced_wry::ExtractedWindowId), + /// Passkey webview manager update. + PasskeyWebviewUpdate(iced_wry::IcedWryMessage), + /// Passkey ceremony completed (registration or authentication). + PasskeyCeremonyResult(Result), + /// Cancel an in-progress passkey ceremony. + CancelPasskeyCeremony, + /// Poll tick for native (macOS) passkey ceremony. + NativePasskeyTick, /// Result of registering a cube with the remote Connect API. CubeRemoteRegistered { cube_id: String, @@ -2297,6 +2699,8 @@ pub enum ViewMessage { ConnectAccount(ConnectAccountMessage), /// Toggle light/dark theme ToggleTheme, + /// Toggle passkey mode for Cube creation (no PIN when enabled). + TogglePasskeyMode(bool), /// Open a URL in the default browser OpenUrl(String), } diff --git a/coincube-gui/src/lib.rs b/coincube-gui/src/lib.rs index 13b317515..5ba600336 100644 --- a/coincube-gui/src/lib.rs +++ b/coincube-gui/src/lib.rs @@ -5,6 +5,7 @@ pub mod delete; pub mod dir; pub mod download; pub mod export; +pub mod feature_flags; pub mod gui; pub mod help; pub mod hw; diff --git a/coincube-gui/src/loader.rs b/coincube-gui/src/loader.rs index 331a88490..d95be204c 100644 --- a/coincube-gui/src/loader.rs +++ b/coincube-gui/src/loader.rs @@ -602,6 +602,8 @@ pub async fn load_application( connect_authenticated: false, has_vault: true, cube_name: config.cube_settings.name.clone(), + current_cube_backed_up: config.cube_settings.backed_up, + current_cube_is_passkey: config.cube_settings.is_passkey_cube(), has_p2p: false, // Set later by App::new based on mnemonic availability theme_mode: coincube_ui::theme::palette::ThemeMode::default(), btc_usd_price: None, diff --git a/coincube-gui/src/services/mod.rs b/coincube-gui/src/services/mod.rs index f1e831498..a89217f8a 100644 --- a/coincube-gui/src/services/mod.rs +++ b/coincube-gui/src/services/mod.rs @@ -9,6 +9,7 @@ pub mod coincube; pub mod lnurl; pub mod mavapay; pub mod meld; +pub mod passkey; pub mod sideshift; /// Resolves the Coincube API base URL with this precedence: diff --git a/coincube-gui/src/services/passkey/macos.rs b/coincube-gui/src/services/passkey/macos.rs new file mode 100644 index 000000000..b5e08be8f --- /dev/null +++ b/coincube-gui/src/services/passkey/macos.rs @@ -0,0 +1,343 @@ +//! Native macOS passkey ceremony via the AuthenticationServices framework. +//! +//! This implementation calls Apple's `ASAuthorizationController` directly +//! through `objc2` bindings. It bypasses the WebAuthn-via-WebView path +//! (which is broken in WKWebView without the browser entitlement) and uses +//! the platform authenticator (Touch ID / Face ID via iCloud Keychain). +//! +//! Requires macOS 14 (Sonoma) or later for the PRF extension. + +#![cfg(target_os = "macos")] +#![allow(unexpected_cfgs)] + +use std::cell::OnceCell; +use std::sync::mpsc; + +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2::{define_class, msg_send, AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly}; +use objc2_authentication_services::{ + ASAuthorization, ASAuthorizationController, ASAuthorizationControllerDelegate, + ASAuthorizationControllerPresentationContextProviding, + ASAuthorizationPlatformPublicKeyCredentialProvider, + ASAuthorizationPlatformPublicKeyCredentialRegistration, + ASAuthorizationPublicKeyCredentialPRFAssertionInputValues, + ASAuthorizationPublicKeyCredentialPRFRegistrationInput, ASPublicKeyCredential, +}; +// IMPORTANT: ASPresentationAnchor is `objc2::runtime::NSObject`, NOT +// `objc2_foundation::NSObject`. To avoid name collisions in `define_class!`, +// we use the runtime NSObject as the superclass and skip importing +// objc2_foundation::NSObject entirely. +use objc2::runtime::NSObject; +use objc2_foundation::{ + NSArray, NSData, NSError, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, +}; + +use rand::RngCore; +use zeroize::Zeroizing; + +/// Result delivered by the delegate to the polling caller. +#[derive(Debug, Clone)] +pub enum NativeOutcome { + Registered { + credential_id: Vec, + prf_output: Zeroizing<[u8; 32]>, + }, + Authenticated { + prf_output: Zeroizing<[u8; 32]>, + }, + Error(String), +} + +/// Salt used by the Breez passkey-login spec — "NYOASTRTSAOYN". +const PRF_SALT: &[u8] = &[ + 0x4e, 0x59, 0x4f, 0x41, 0x53, 0x54, 0x52, 0x54, 0x53, 0x41, 0x4f, 0x59, 0x4e, +]; + +/// Instance variables for the delegate. +struct DelegateIvars { + /// Channel to send the result back to Rust async code. + sender: OnceCell>, +} + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "CoincubePasskeyDelegate"] + #[ivars = DelegateIvars] + struct PasskeyDelegate; + + unsafe impl NSObjectProtocol for PasskeyDelegate {} + + unsafe impl ASAuthorizationControllerDelegate for PasskeyDelegate { + #[unsafe(method(authorizationController:didCompleteWithAuthorization:))] + fn did_complete_with_authorization( + &self, + _controller: &ASAuthorizationController, + authorization: &ASAuthorization, + ) { + let outcome = unsafe { extract_outcome(authorization) }; + if let Some(sender) = self.ivars().sender.get() { + let _ = sender.send(outcome); + } + } + + #[unsafe(method(authorizationController:didCompleteWithError:))] + fn did_complete_with_error( + &self, + _controller: &ASAuthorizationController, + error: &NSError, + ) { + let desc = error.localizedDescription(); + let msg = desc.to_string(); + let code = error.code(); + let full = format!("{} (code {})", msg, code); + if let Some(sender) = self.ivars().sender.get() { + let _ = sender.send(NativeOutcome::Error(full)); + } + } + } + + unsafe impl ASAuthorizationControllerPresentationContextProviding for PasskeyDelegate { + #[unsafe(method_id(presentationAnchorForAuthorizationController:))] + fn presentation_anchor_for_authorization_controller( + &self, + _controller: &ASAuthorizationController, + ) -> Retained { + // Return the app's key window (or main window as fallback) via raw + // msg_send! to avoid pulling in objc2-app-kit (which conflicts with + // the older version from iced/winit). The selectors here are + // standard Cocoa: NSApplication.sharedApplication, then -keyWindow. + unsafe { + use objc2::class; + use objc2::runtime::AnyObject; + + let mut window: *mut AnyObject = std::ptr::null_mut(); + + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + if !app.is_null() { + window = msg_send![app, keyWindow]; + if window.is_null() { + window = msg_send![app, mainWindow]; + } + if window.is_null() { + let windows: *mut AnyObject = msg_send![app, windows]; + if !windows.is_null() { + window = msg_send![windows, firstObject]; + } + } + } else { + tracing::warn!( + "NSApplication.sharedApplication returned nil; \ + passkey presentation will use a fallback hidden window" + ); + } + + if window.is_null() { + if !app.is_null() { + tracing::warn!( + "No NSWindow available for passkey presentation; \ + creating a fallback hidden window" + ); + } + // Fallback: create a minimal hidden NSWindow so the + // authorization controller has a valid presentation anchor. + let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(1.0, 1.0)); + let alloc: *mut AnyObject = msg_send![class!(NSWindow), alloc]; + window = msg_send![ + alloc, + initWithContentRect: rect, + styleMask: 0u64, // NSWindowStyleMaskBorderless + backing: 2u64, // NSBackingStoreBuffered + defer: true, + ]; + } + + // Retain the window and return as Retained + // (which is what ASPresentationAnchor aliases to). + let _: () = msg_send![window, retain]; + Retained::from_raw(window as *mut NSObject) + .expect("Failed to obtain or create NSWindow for passkey presentation") + } + } + } +); + +impl PasskeyDelegate { + fn new(mtm: MainThreadMarker, sender: mpsc::Sender) -> Retained { + let cell = OnceCell::new(); + let _ = cell.set(sender); + let ivars = DelegateIvars { sender: cell }; + let this = Self::alloc(mtm).set_ivars(ivars); + unsafe { msg_send![super(this), init] } + } +} + +/// Extract the credential ID and PRF output from a successful authorization. +unsafe fn extract_outcome(authorization: &ASAuthorization) -> NativeOutcome { + let credential = unsafe { authorization.credential() }; + + // ProtocolObject implements AsRef, so we go through that. + let any_obj: &objc2::runtime::AnyObject = credential.as_ref(); + let reg = match any_obj.downcast_ref::() + { + Some(r) => r, + None => { + return NativeOutcome::Error( + "Unexpected credential type returned by AuthenticationServices".to_string(), + ) + } + }; + + // credentialID() comes from the ASPublicKeyCredential trait. + let credential_id_data = unsafe { reg.credentialID() }; + let credential_id = credential_id_data.to_vec(); + + let prf = match unsafe { reg.prf() } { + Some(p) => p, + None => { + return NativeOutcome::Error("PRF extension not supported by this passkey".to_string()) + } + }; + + let first = match unsafe { prf.first() } { + Some(d) => d, + None => return NativeOutcome::Error("PRF output missing first value".to_string()), + }; + + let bytes = first.to_vec(); + if bytes.len() < 32 { + return NativeOutcome::Error(format!( + "PRF output too short: {} bytes (expected at least 32)", + bytes.len() + )); + } + + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes[..32]); + + NativeOutcome::Registered { + credential_id, + prf_output: Zeroizing::new(arr), + } +} + +/// Active passkey ceremony — holds the controller, delegate, and channel receiver. +/// +/// Drop this to cancel the ceremony. +pub struct NativePasskeyCeremony { + controller: Retained, + _delegate: Retained, + receiver: mpsc::Receiver, +} + +impl NativePasskeyCeremony { + /// Start a passkey registration ceremony. + /// + /// `rp_id` is the relying party identifier (e.g. "coincube.io"). + /// `user_id` is the unique user identifier (Cube UUID as bytes). + /// `user_name` is the display name shown in the system UI. + pub fn register(rp_id: &str, user_id: &[u8], user_name: &str) -> Result { + let mtm = MainThreadMarker::new() + .ok_or_else(|| "Passkey ceremony must be started on the main thread".to_string())?; + + unsafe { + // Build provider + let rp_ns = NSString::from_str(rp_id); + let provider = + ASAuthorizationPlatformPublicKeyCredentialProvider::initWithRelyingPartyIdentifier( + ASAuthorizationPlatformPublicKeyCredentialProvider::alloc(), + &rp_ns, + ); + + // Random 32-byte challenge + let mut challenge_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut challenge_bytes); + let challenge = NSData::with_bytes(&challenge_bytes); + + let user_id_data = NSData::with_bytes(user_id); + let user_name_ns = NSString::from_str(user_name); + + // Create registration request + let request = provider.createCredentialRegistrationRequestWithChallenge_name_userID( + &challenge, + &user_name_ns, + &user_id_data, + ); + + // Attach PRF extension with our salt + let salt_data = NSData::with_bytes(PRF_SALT); + let prf_values = + ASAuthorizationPublicKeyCredentialPRFAssertionInputValues::initWithSaltInput1_saltInput2( + ASAuthorizationPublicKeyCredentialPRFAssertionInputValues::alloc(), + &salt_data, + None, + ); + let prf_input = + ASAuthorizationPublicKeyCredentialPRFRegistrationInput::initWithInputValues( + ASAuthorizationPublicKeyCredentialPRFRegistrationInput::alloc(), + Some(&prf_values), + ); + request.setPrf(Some(&prf_input)); + + // Build the requests array. We need NSArray. + // The registration request is a subclass of ASAuthorizationRequest, + // so we cast through the superclass relationship. + let request_super: &objc2_authentication_services::ASAuthorizationRequest = &request; + let requests_array = NSArray::from_slice(&[request_super]); + + let controller = ASAuthorizationController::initWithAuthorizationRequests( + ASAuthorizationController::alloc(), + &requests_array, + ); + + // Set up delegate with channel + let (tx, rx) = mpsc::channel(); + let delegate = PasskeyDelegate::new(mtm, tx); + let delegate_proto: &ProtocolObject = + ProtocolObject::from_ref(&*delegate); + controller.setDelegate(Some(delegate_proto)); + + // The same delegate also provides the presentation anchor + // (the NSWindow over which the passkey sheet is shown). + let presentation_proto: &ProtocolObject< + dyn ASAuthorizationControllerPresentationContextProviding, + > = ProtocolObject::from_ref(&*delegate); + controller.setPresentationContextProvider(Some(presentation_proto)); + + // Start the ceremony + controller.performRequests(); + + Ok(Self { + controller, + _delegate: delegate, + receiver: rx, + }) + } + } + + /// Start a passkey authentication ceremony. + pub fn authenticate(rp_id: &str, _credential_id: &[u8]) -> Result { + // TODO: Implement assertion (authentication) flow. + let _ = rp_id; + Err("Native passkey authentication not yet implemented".to_string()) + } + + /// Poll for a result (non-blocking). + pub fn try_recv(&self) -> Option { + self.receiver.try_recv().ok() + } + + /// Cancel the in-progress ceremony. + pub fn cancel(&self) { + unsafe { + self.controller.cancel(); + } + } +} + +impl Drop for NativePasskeyCeremony { + fn drop(&mut self) { + self.cancel(); + } +} diff --git a/coincube-gui/src/services/passkey/mod.rs b/coincube-gui/src/services/passkey/mod.rs new file mode 100644 index 000000000..bc8810bd6 --- /dev/null +++ b/coincube-gui/src/services/passkey/mod.rs @@ -0,0 +1,326 @@ +//! Passkey ceremony service for WebAuthn + PRF-based master key derivation. +//! +//! On macOS, this uses the native AuthenticationServices framework via +//! `objc2-authentication-services` (see [`macos`] submodule). On other +//! platforms, it falls back to an embedded webview pointing at the hosted +//! ceremony page at `coincube.io/passkey`. + +#[cfg(target_os = "macos")] +pub mod macos; + +use std::sync::{mpsc, Arc}; + +use zeroize::Zeroizing; + +/// Base URL for the passkey ceremony page. +/// +/// Configured at build time via `COINCUBE_PASSKEY_CEREMONY_URL` (forwarded by +/// `build.rs` from `.env`). Defaults to a local dev URL so non-production +/// builds don't point at a non-existent hosted endpoint. Production deploys +/// must set this to the actual ceremony page URL. +pub const CEREMONY_BASE_URL: &str = match option_env!("COINCUBE_PASSKEY_CEREMONY_URL") { + Some(v) => v, + None => "http://localhost:8080/passkey", +}; + +/// Relying Party ID — must match the ceremony page's domain. +/// +/// Configured at build time via `COINCUBE_PASSKEY_RP_ID`. Production deploys +/// must set this to the actual domain hosting the ceremony page. +pub const RP_ID: &str = match option_env!("COINCUBE_PASSKEY_RP_ID") { + Some(v) => v, + None => "localhost", +}; + +/// Errors that can occur during a passkey ceremony. +#[derive(Debug, Clone)] +pub enum PasskeyError { + /// The ceremony page reported an error via IPC. + CeremonyFailed(String), + /// The webview failed to initialize. + WebviewFailed(String), + /// The IPC response could not be parsed. + InvalidResponse(String), + /// The PRF output was not the expected 32 bytes. + InvalidPrfOutput, + /// The user cancelled the ceremony. + Cancelled, + /// The PRF extension is not supported on this platform. + PrfNotSupported, +} + +impl std::fmt::Display for PasskeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CeremonyFailed(msg) => write!(f, "Passkey ceremony failed: {}", msg), + Self::WebviewFailed(msg) => write!(f, "Webview initialization failed: {}", msg), + Self::InvalidResponse(msg) => write!(f, "Invalid ceremony response: {}", msg), + Self::InvalidPrfOutput => write!(f, "PRF output is not 32 bytes"), + Self::Cancelled => write!(f, "Passkey ceremony was cancelled"), + Self::PrfNotSupported => write!(f, "PRF extension is not supported on this platform"), + } + } +} + +/// Result of a successful passkey registration ceremony. +#[derive(Clone)] +pub struct PasskeyRegistration { + /// Base64-encoded WebAuthn credential ID. + pub credential_id: String, + /// 32-byte PRF output (secret — zeroized on drop). + pub prf_output: Zeroizing<[u8; 32]>, +} + +/// Result of a successful passkey authentication ceremony. +#[derive(Clone)] +pub struct PasskeyAuthentication { + /// 32-byte PRF output (secret — zeroized on drop). + pub prf_output: Zeroizing<[u8; 32]>, +} + +/// Parsed IPC message from the ceremony page. +/// +/// `prf_output` is sent by the ceremony page as a JSON array of byte values +/// (e.g. `[0,1,2,...,31]`). Exact 32-byte length is validated after +/// deserialization before converting into `Zeroizing<[u8; 32]>`. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "type")] +enum CeremonyIpcMessage { + #[serde(rename = "register_success")] + RegisterSuccess { + credential_id: String, + prf_output: Vec, + }, + #[serde(rename = "authenticate_success")] + AuthenticateSuccess { prf_output: Vec }, + #[serde(rename = "error")] + Error { message: String }, +} + +/// The kind of passkey ceremony to perform. +#[derive(Debug, Clone)] +pub enum CeremonyMode { + /// Register a new passkey for a new Cube. + Register { user_id: String, user_name: String }, + /// Authenticate with an existing passkey to open a Cube. + Authenticate { credential_id: String }, +} + +/// Percent-encode a string for use in URL query parameters. +fn url_encode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + _ => { + result.push_str(&format!("%{:02X}", byte)); + } + } + } + result +} + +impl CeremonyMode { + /// Build the full URL for the ceremony page. + pub fn url(&self) -> String { + match self { + Self::Register { user_id, user_name } => { + format!( + "{}?mode=register&user_id={}&user_name={}", + CEREMONY_BASE_URL, + url_encode(user_id), + url_encode(user_name), + ) + } + Self::Authenticate { credential_id } => { + format!( + "{}?mode=authenticate&credential_id={}", + CEREMONY_BASE_URL, + url_encode(credential_id), + ) + } + } + } +} + +/// Shared state for receiving IPC messages from the webview. +/// +/// The sender is captured by the webview's IPC handler closure; +/// the receiver is polled by the iced subscription. +pub struct PasskeyCeremonyChannel { + sender: mpsc::Sender, + receiver: mpsc::Receiver, +} + +impl Default for PasskeyCeremonyChannel { + fn default() -> Self { + Self::new() + } +} + +impl PasskeyCeremonyChannel { + pub fn new() -> Self { + let (sender, receiver) = mpsc::channel(); + Self { sender, receiver } + } + + /// Get a clone of the sender for use in the IPC handler closure. + pub fn sender(&self) -> mpsc::Sender { + self.sender.clone() + } + + /// Try to receive an IPC message (non-blocking). + pub fn try_recv(&self) -> Option { + self.receiver.try_recv().ok() + } +} + +/// Manages the passkey ceremony webview lifecycle. +/// +/// Usage: +/// 1. Create with `PasskeyCeremony::new(mode)` +/// 2. Call `create_webview(window_id)` once the window ID is extracted +/// 3. Poll `try_recv_result()` in the iced subscription +/// 4. Drop to clean up the webview +pub struct PasskeyCeremony { + pub mode: CeremonyMode, + pub webview_manager: iced_wry::IcedWebviewManager, + pub active_webview: Option, + channel: Arc, +} + +impl PasskeyCeremony { + pub fn new(mode: CeremonyMode) -> Self { + Self { + mode, + webview_manager: iced_wry::IcedWebviewManager::new(), + active_webview: None, + #[allow(clippy::arc_with_non_send_sync)] + channel: Arc::new(PasskeyCeremonyChannel::new()), + } + } + + /// Create the webview and start the ceremony. + /// + /// Returns `true` if the webview was created successfully. + pub fn create_webview(&mut self, window_id: iced_wry::ExtractedWindowId) -> bool { + let url = self.mode.url(); + let tx = self.channel.sender(); + + let attrs = iced_wry::wry::WebViewAttributes { + url: Some(url), + incognito: true, + devtools: cfg!(debug_assertions), + ipc_handler: Some(Box::new(move |req| { + let body = req.body().clone(); + let _ = tx.send(body); + })), + ..Default::default() + }; + + match self.webview_manager.new_webview(attrs, window_id) { + Some(active) => { + self.active_webview = Some(active); + true + } + None => false, + } + } + + /// Poll for a ceremony result (non-blocking). + /// + /// Returns `Some(Ok(...))` on success, `Some(Err(...))` on failure, + /// or `None` if no result yet. + pub fn try_recv_result(&self) -> Option> { + let raw = self.channel.try_recv()?; + + let parsed: CeremonyIpcMessage = match serde_json::from_str(&raw) { + Ok(msg) => msg, + Err(e) => { + return Some(Err(PasskeyError::InvalidResponse(format!( + "Failed to parse IPC: {}", + e + )))) + } + }; + + Some(match parsed { + CeremonyIpcMessage::RegisterSuccess { + credential_id, + prf_output, + } => { + let prf_output = Zeroizing::new(prf_output); + if prf_output.len() != 32 { + Err(PasskeyError::InvalidPrfOutput) + } else { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&prf_output); + Ok(CeremonyOutcome::Registered(PasskeyRegistration { + credential_id, + prf_output: Zeroizing::new(arr), + })) + } + } + CeremonyIpcMessage::AuthenticateSuccess { prf_output } => { + let prf_output = Zeroizing::new(prf_output); + if prf_output.len() != 32 { + Err(PasskeyError::InvalidPrfOutput) + } else { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&prf_output); + Ok(CeremonyOutcome::Authenticated(PasskeyAuthentication { + prf_output: Zeroizing::new(arr), + })) + } + } + CeremonyIpcMessage::Error { message } => { + if message.contains("cancelled") || message.contains("NotAllowedError") { + Err(PasskeyError::Cancelled) + } else if message.contains("PRF") || message.contains("not supported") { + Err(PasskeyError::PrfNotSupported) + } else { + Err(PasskeyError::CeremonyFailed(message)) + } + } + }) + } + + /// Clean up the webview. + pub fn close(&mut self) { + if let Some(active) = self.active_webview.take() { + self.webview_manager.clear_view(&active); + } + } +} + +impl Drop for PasskeyCeremony { + fn drop(&mut self) { + self.close(); + } +} + +/// The outcome of a successful ceremony. +#[derive(Clone)] +pub enum CeremonyOutcome { + Registered(PasskeyRegistration), + Authenticated(PasskeyAuthentication), +} + +// Manual Debug impl to avoid printing PRF output. +impl std::fmt::Debug for CeremonyOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Registered(r) => f + .debug_struct("Registered") + .field("credential_id", &r.credential_id) + .field("prf_output", &"") + .finish(), + Self::Authenticated(_) => f + .debug_struct("Authenticated") + .field("prf_output", &"") + .finish(), + } + } +} diff --git a/coincube-gui/src/signer.rs b/coincube-gui/src/signer.rs index d1e4e8d61..b96eb9d41 100644 --- a/coincube-gui/src/signer.rs +++ b/coincube-gui/src/signer.rs @@ -7,14 +7,14 @@ use coincube_core::{ psbt::Psbt, secp256k1, Network, }, - signer::{self, HotSigner}, + signer::{self, MasterSigner}, }; use crate::dir::{CoincubeDirectory, NetworkDirectory}; pub struct Signer { curve: secp256k1::Secp256k1, - key: HotSigner, + key: MasterSigner, pub fingerprint: Fingerprint, } @@ -25,7 +25,7 @@ impl std::fmt::Debug for Signer { } impl Signer { - pub fn new(key: HotSigner) -> Self { + pub fn new(key: MasterSigner) -> Self { let curve = secp256k1::Secp256k1::new(); let fingerprint = key.fingerprint(&curve); Self { @@ -44,7 +44,7 @@ impl Signer { } pub fn generate(network: Network) -> Result { - Ok(Self::new(HotSigner::generate(network)?)) + Ok(Self::new(MasterSigner::generate(network)?)) } pub fn fingerprint(&self) -> Fingerprint { @@ -55,6 +55,16 @@ impl Signer { self.key.xpub_at(path, &self.curve) } + /// Derive a Border Wallet GridRecoveryPhrase from the master key via BIP-85. + pub fn derive_grid_recovery_phrase( + &self, + ) -> Result< + coincube_core::border_wallet::GridRecoveryPhrase, + coincube_core::border_wallet::BorderWalletError, + > { + coincube_core::border_wallet::GridRecoveryPhrase::from_master_signer(&self.key, &self.curve) + } + pub fn sign_psbt(&self, psbt: Psbt) -> Result { self.key.sign_psbt(psbt, &self.curve) } diff --git a/coincube-gui/test_assets/global_settings.json b/coincube-gui/test_assets/global_settings.json index 6b86d25f7..54f843a83 100644 --- a/coincube-gui/test_assets/global_settings.json +++ b/coincube-gui/test_assets/global_settings.json @@ -77,5 +77,8 @@ "width": 1248.0, "height": 688.0 }, - "developer_mode": false + "developer_mode": false, + "account_tier": "free", + "theme_mode": "dark", + "show_direction_badges": true } \ No newline at end of file diff --git a/coincube-ui/src/component/hw.rs b/coincube-ui/src/component/hw.rs index 654e7aeb2..e5bdc1b36 100644 --- a/coincube-ui/src/component/hw.rs +++ b/coincube-ui/src/component/hw.rs @@ -502,7 +502,7 @@ pub fn unsupported_version_hardware_wallet<'a, T: 'static, K: Display, V: Displa .padding(10) } -pub fn sign_success_hot_signer<'a, T: 'a, F: Display>( +pub fn sign_success_master_signer<'a, T: 'a, F: Display>( fingerprint: F, alias: Option>>, ) -> Container<'a, T> { @@ -534,7 +534,7 @@ pub fn sign_success_hot_signer<'a, T: 'a, F: Display>( .padding(10) } -pub fn selected_hot_signer<'a, T: 'a, F: Display>( +pub fn selected_master_signer<'a, T: 'a, F: Display>( fingerprint: F, alias: Option>>, ) -> Container<'a, T> { @@ -563,7 +563,7 @@ pub fn selected_hot_signer<'a, T: 'a, F: Display>( .padding(10) } -pub fn unselected_hot_signer<'a, T: 'a, F: Display>( +pub fn unselected_master_signer<'a, T: 'a, F: Display>( fingerprint: F, alias: Option>>, ) -> Container<'a, T> { @@ -587,7 +587,7 @@ pub fn unselected_hot_signer<'a, T: 'a, F: Display>( .padding(10) } -pub fn hot_signer<'a, T: 'a, F: Display>( +pub fn master_signer<'a, T: 'a, F: Display>( fingerprint: F, alias: Option>>, can_sign: bool, @@ -608,7 +608,7 @@ pub fn hot_signer<'a, T: 'a, F: Display>( .push(Space::new().width(Length::Fixed(20.0))) .push(if !can_sign { Some(text::text( - "This hot signer is not part of this spending path.", + "This master signer is not part of this spending path.", )) } else { None diff --git a/docs/TRY.md b/docs/TRY.md index 12b6c8c37..1db2edffb 100644 --- a/docs/TRY.md +++ b/docs/TRY.md @@ -124,7 +124,7 @@ Choose to create a new wallet. Choose **Bitcoin Signet** as network. Now you will need to configure the primary key(s), the recovery key(s), and the time delay before the recovery keys become available (in # of blocks). We'll use -only one key for both the primary and recovery paths. We'll derive both keys from a "hot signer", a +only one key for both the primary and recovery paths. We'll derive both keys from a "master signer", a HD wallet whose seed is stored on the laptop. Click on "Set" for the primary key. Click on "This computer" and set an alias for this signer. I'll diff --git a/tests/tools/taproot_signer/README.md b/tests/tools/taproot_signer/README.md index 00638e280..4d6f0e0d5 100644 --- a/tests/tools/taproot_signer/README.md +++ b/tests/tools/taproot_signer/README.md @@ -1,4 +1,4 @@ -A quick and dirty program to sign Taproot PSBTs with a given xpriv. This reuses the hot signer Rust +A quick and dirty program to sign Taproot PSBTs with a given xpriv. This reuses the master signer Rust code so i don't have to reimplement everything in Python. Usage: diff --git a/tests/tools/taproot_signer/src/main.rs b/tests/tools/taproot_signer/src/main.rs index 0b9c3bdc1..bc3b6c448 100644 --- a/tests/tools/taproot_signer/src/main.rs +++ b/tests/tools/taproot_signer/src/main.rs @@ -1,5 +1,5 @@ //! A quick and dirty program which reads a PSBT and an xpriv from stdin and outputs the signed -//! PSBT to stdout. Uses function copied from Coincube's hot signer and adapted. +//! PSBT to stdout. Uses function copied from Coincube's master signer and adapted. use std::{ env,