From 7d4576c81d87ddf04ceee5d49bbcb3f6138692fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sun, 19 Jul 2026 16:28:21 -0400 Subject: [PATCH 1/9] Support self-signed and privately issued Electrum certificates Custom Electrum nodes could only be reached over TLS chains anchored in the bundled webpki roots, so a self hosted server was unreachable unless its certificate came from a public CA. Node now carries an optional TlsTrust describing how its certificate is verified. A custom CA validates the chain and still checks the hostname, so a privately issued leaf can rotate without the user pinning it again. A pinned SHA-256 fingerprint accepts one specific leaf and skips the hostname check, covering servers reached by IP whose certificate has no matching SAN. Leaving it unset keeps the previous behavior. electrum-client only accepts a caller supplied rustls session through RawClient, which has no reconnect loop of its own, so Transport pairs it with the url and trust settings needed to rebuild it. Without that a single dropped socket would break a pinned node until the app restarts, because the client is cached for the lifetime of a wallet. Reconnects track a generation so callers that failed on the same dead socket reuse the connection the first of them established. Only a rejected certificate counts as a certificate failure. An ssl:// url pointed at a plaintext port stays a connection error, so the user is never asked to trust their way out of an unrelated problem. Pinning still verifies the handshake signature, so the peer has to hold the pinned certificate's key. The handshake is completed while creating the client rather than on first use, so a rejected certificate is reported the way the default path reports one. --- rust/Cargo.lock | 181 +++++ rust/Cargo.toml | 3 +- rust/src/node.rs | 51 +- rust/src/node/client.rs | 24 + rust/src/node/client/electrum.rs | 22 +- rust/src/node/client/electrum/test_server.rs | 185 +++++ rust/src/node/client/electrum/transport.rs | 677 +++++++++++++++++++ rust/src/node/tls.rs | 289 ++++++++ 8 files changed, 1425 insertions(+), 7 deletions(-) create mode 100644 rust/src/node/client/electrum/test_server.rs create mode 100644 rust/src/node/client/electrum/transport.rs create mode 100644 rust/src/node/tls.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a421db1af..cc2c39fe9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -331,6 +331,45 @@ dependencies = [ "winnow 1.0.2", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -631,6 +670,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -1248,6 +1296,7 @@ dependencies = [ "pretty_assertions", "pubport", "rand 0.10.1", + "rcgen", "redb", "reqwest 0.13.3", "rusqlite", @@ -1653,6 +1702,26 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "2.1.1" @@ -3089,6 +3158,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -3147,6 +3222,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3272,6 +3356,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3425,6 +3519,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3771,6 +3871,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redb" version = "2.6.3" @@ -3961,6 +4075,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4541,6 +4664,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5705,6 +5858,24 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xshell" version = "0.2.7" @@ -5757,6 +5928,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e9eb7a0b3..366f57b43 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -253,7 +253,7 @@ ahash = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-no-provider"], default-features = false } # tls (needed for crypto provider install in bootstrap) -rustls = { version = "0.23.40", features = ["ring"], default-features = false } +rustls = { version = "0.23.40", features = ["ring", "tls12"], default-features = false } # parsing winnow = { workspace = true } @@ -295,6 +295,7 @@ uniffi = { workspace = true, features = ["build"] } bdk_wallet = { workspace = true, features = ["test-utils"] } tempfile = "3.27.0" pretty_assertions = "1.4.1" +rcgen = "0.14.8" minicbor = { workspace = true } tokio = { workspace = true, features = ["rt", "test-util", "macros"] } diff --git a/rust/src/node.rs b/rust/src/node.rs index 1902ebc57..cfcf25a02 100644 --- a/rust/src/node.rs +++ b/rust/src/node.rs @@ -1,5 +1,6 @@ pub mod client; pub mod client_builder; +pub mod tls; use crate::node_connect::{ BITCOIN_ELECTRUM, NodeSelection, SIGNET_ESPLORA, TESTNET_ESPLORA, TESTNET4_ESPLORA, @@ -35,6 +36,12 @@ pub struct Node { pub network: Network, pub api_type: ApiType, pub url: String, + + /// How the node's TLS certificate is verified. `None` uses the bundled + /// webpki roots, which is the behavior every node had before this field. + #[serde(default)] + #[uniffi(default = None)] + pub tls: Option, } #[derive(Debug, thiserror::Error)] @@ -43,6 +50,13 @@ pub enum Error { CheckUrlError(#[from] client::Error), } +impl Error { + pub fn is_certificate_error(&self) -> bool { + let Self::CheckUrlError(error) = self; + error.is_certificate_error() + } +} + impl Node { pub fn default(network: Network) -> Self { match network { @@ -54,6 +68,7 @@ impl Node { network, api_type: ApiType::Electrum, url: url.to_string(), + tls: None, } } Network::Testnet => { @@ -63,6 +78,7 @@ impl Node { network, api_type: ApiType::Electrum, url: url.to_string(), + tls: None, } } @@ -73,6 +89,7 @@ impl Node { network, api_type: ApiType::Esplora, url: url.to_string(), + tls: None, } } @@ -83,17 +100,18 @@ impl Node { network, api_type: ApiType::Esplora, url: url.to_string(), + tls: None, } } } } pub const fn new_electrum(name: String, url: String, network: Network) -> Self { - Self { name, network, api_type: ApiType::Electrum, url } + Self { name, network, api_type: ApiType::Electrum, url, tls: None } } pub const fn new_esplora(name: String, url: String, network: Network) -> Self { - Self { name, network, api_type: ApiType::Esplora, url } + Self { name, network, api_type: ApiType::Esplora, url, tls: None } } pub async fn check_url(&self) -> Result<(), Error> { @@ -112,3 +130,32 @@ impl From for Node { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Nodes saved before `tls` existed are still in the database, so the field + /// has to stay optional on the way in. + #[test] + fn nodes_saved_without_tls_still_load() { + let node = Node::default(Network::Bitcoin); + + // Drop the key to reproduce exactly what an older build wrote. + let mut stored = serde_json::to_value(&node).unwrap(); + stored.as_object_mut().unwrap().remove("tls").expect("tls is serialized"); + + assert_eq!(serde_json::from_value::(stored).unwrap(), node); + } + + #[test] + fn tls_settings_survive_a_round_trip() { + let node = Node { + tls: Some(tls::TlsTrust::PinnedFingerprint { sha256: vec![7; 32] }), + ..Node::default(Network::Bitcoin) + }; + + let encoded = serde_json::to_string(&node).unwrap(); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), node); + } +} diff --git a/rust/src/node/client.rs b/rust/src/node/client.rs index f0c89d75c..b300997ca 100644 --- a/rust/src/node/client.rs +++ b/rust/src/node/client.rs @@ -53,6 +53,12 @@ pub enum Error { #[error("failed to create node client: {0}")] CreateElectrumClient(electrum_client::Error), + #[error("failed to create node client with custom certificate settings: {0}")] + CreateElectrumTlsClient(electrum::transport::ConnectError), + + #[error("{0} nodes do not support custom certificate settings")] + TlsTrustUnsupported(ApiType), + #[error("failed to connect to node: {0}")] EsploraConnect(esplora_client::Error), @@ -87,6 +93,24 @@ pub enum Error { ElectrumGetTransaction(electrum_client::Error), } +impl Error { + /// Whether the node was reachable but its certificate was not accepted, the + /// one failure the user can resolve by trusting the certificate. + pub fn is_certificate_error(&self) -> bool { + match self { + Self::CreateElectrumTlsClient(error) => { + matches!(error, electrum::transport::ConnectError::CertificateRejected(_)) + } + Self::CreateElectrumClient(electrum_client::Error::IOError(error)) + | Self::ElectrumConnect(electrum_client::Error::IOError(error)) => error + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + .is_some_and(|error| matches!(error, rustls::Error::InvalidCertificate(_))), + _ => false, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NodeClientOptions { pub batch_size: usize, diff --git a/rust/src/node/client/electrum.rs b/rust/src/node/client/electrum.rs index 363c4a3e3..055ce6ed1 100644 --- a/rust/src/node/client/electrum.rs +++ b/rust/src/node/client/electrum.rs @@ -21,10 +21,16 @@ use tap::TapFallible as _; use tokio_util::sync::CancellationToken; use tracing::{debug, error, warn}; +pub mod transport; + +#[cfg(test)] +pub mod test_server; + use super::{ELECTRUM_BATCH_SIZE, Error, NodeClientOptions}; use crate::node::Node; +use transport::Transport; -type ElectrumClientInner = BdkElectrumClient; +type ElectrumClientInner = BdkElectrumClient; #[derive(Debug, Deserialize)] struct ElectrumTransactionResponse { @@ -59,11 +65,18 @@ impl ElectrumClient { options: NodeClientOptions, ) -> Result { let url = node.url.strip_suffix('/').unwrap_or(&node.url).to_string(); + let trust = node.tls.clone(); // use spawn_blocking for the synchronous TCP connection to avoid blocking the async runtime - let inner_client = cove_tokio::unblock::run_blocking(move || Client::new(&url)) - .await - .map_err(Error::CreateElectrumClient)?; + let inner_client = cove_tokio::unblock::run_blocking(move || match trust { + None => Client::new(&url) + .map(|client| Transport::Default(Box::new(client))) + .map_err(Error::CreateElectrumClient), + Some(trust) => { + Transport::connect_pinned(&url, &trust).map_err(Error::CreateElectrumTlsClient) + } + }) + .await?; let bdk_client = BdkElectrumClient::new(inner_client); let client = Arc::new(bdk_client); @@ -342,6 +355,7 @@ mod tests { name: "blockstream".to_string(), api_type: crate::node::ApiType::Electrum, network: cove_types::network::Network::Bitcoin, + tls: None, }) .await .unwrap(); diff --git a/rust/src/node/client/electrum/test_server.rs b/rust/src/node/client/electrum/test_server.rs new file mode 100644 index 000000000..8f51c5e42 --- /dev/null +++ b/rust/src/node/client/electrum/test_server.rs @@ -0,0 +1,185 @@ +//! A TLS listener speaking just enough of the Electrum protocol to exercise +//! certificate verification. + +use std::io::{BufRead as _, BufReader, Write as _}; +use std::net::TcpListener; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::{ServerConfig, ServerConnection, StreamOwned}; + +use crate::node::tls::{self, TlsTrust}; + +pub const TEST_HEIGHT: usize = 840_000; + +const GENESIS_HEADER: &str = "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c"; + +/// The client installs its own crypto provider, but the test server and cove's +/// blocking helper both need process-level setup. +pub fn setup() { + crate::test_support::ensure_tokio_runtime(); + + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } +} + +pub fn self_signed(name: &str) -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let generated = rcgen::generate_simple_self_signed(vec![name.to_string()]).unwrap(); + + ( + CertificateDer::from(generated.cert.der().to_vec()), + PrivateKeyDer::try_from(generated.signing_key.serialize_der()).unwrap(), + ) +} + +/// A certificate authority that issues leaves, standing in for a self hosted CA. +pub struct Authority { + certificate: CertificateDer<'static>, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, +} + +impl Authority { + pub fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + + let key = rcgen::KeyPair::generate().unwrap(); + + let mut params = rcgen::CertificateParams::new(Vec::new()).unwrap(); + // A distinct name per authority, so an unrelated CA is rejected as an + // unknown issuer rather than on a signature mismatch. + params.distinguished_name.push( + rcgen::DnType::CommonName, + format!("cove test ca {}", NEXT.fetch_add(1, Ordering::Relaxed)), + ); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.key_usages = + vec![rcgen::KeyUsagePurpose::KeyCertSign, rcgen::KeyUsagePurpose::CrlSign]; + + let certificate = params.self_signed(&key).unwrap(); + let certificate = CertificateDer::from(certificate.der().to_vec()); + + Self { certificate, issuer: rcgen::Issuer::new(params, key) } + } + + pub fn trust(&self) -> TlsTrust { + TlsTrust::CustomCa { cert: self.certificate.as_ref().to_vec() } + } + + fn issue(&self, name: &str) -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let key = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec![name.to_string()]).unwrap(); + let leaf = params.signed_by(&key, &self.issuer).unwrap(); + + ( + CertificateDer::from(leaf.der().to_vec()), + PrivateKeyDer::try_from(key.serialize_der()).unwrap(), + ) + } +} + +pub struct TestServer { + pub port: u16, + certificate: CertificateDer<'static>, + hang_up: Arc, +} + +impl TestServer { + pub fn self_signed(name: &str) -> Self { + let (certificate, key) = self_signed(name); + Self::spawn("127.0.0.1:0", certificate, vec![], key).expect("bind loopback") + } + + /// Returns `None` where IPv6 loopback is unavailable. + pub fn self_signed_ipv6(name: &str) -> Option { + let (certificate, key) = self_signed(name); + Self::spawn("[::1]:0", certificate, vec![], key) + } + + /// Serves a leaf issued by `authority`, presenting the CA in the chain the + /// way a real server would. + pub fn issued_by(authority: &Authority, name: &str) -> Self { + let (certificate, key) = authority.issue(name); + let chain = vec![authority.certificate.clone()]; + + Self::spawn("127.0.0.1:0", certificate, chain, key).expect("bind loopback") + } + + pub fn fingerprint_trust(&self) -> TlsTrust { + TlsTrust::PinnedFingerprint { sha256: tls::fingerprint(&self.certificate).to_vec() } + } + + /// Close the connection after the next answer, so a caller has to + /// reconnect. Only the next one, the way a real dropped socket behaves. + pub fn hang_up_once(&self) { + self.hang_up.store(true, Ordering::Relaxed); + } + + fn spawn( + bind: &str, + certificate: CertificateDer<'static>, + chain: Vec>, + key: PrivateKeyDer<'static>, + ) -> Option { + let mut presented = vec![certificate.clone()]; + presented.extend(chain); + + let config = + ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert(presented, key) + .unwrap(); + + let listener = TcpListener::bind(bind).ok()?; + let port = listener.local_addr().unwrap().port(); + + let config = Arc::new(config); + let hang_up = Arc::new(AtomicBool::new(false)); + let server_hang_up = hang_up.clone(); + + std::thread::spawn(move || { + for tcp in listener.incoming().flatten() { + let config = config.clone(); + let hang_up = server_hang_up.clone(); + + std::thread::spawn(move || { + let Ok(session) = ServerConnection::new(config) else { return }; + let mut reader = BufReader::new(StreamOwned::new(session, tcp)); + let mut line = String::new(); + + while reader.read_line(&mut line).unwrap_or(0) > 0 { + if reader.get_mut().write_all(response(&line).as_bytes()).is_err() { + return; + } + + let _ = reader.get_mut().flush(); + + if hang_up.swap(false, Ordering::Relaxed) { + return; + } + + line.clear(); + } + }); + } + }); + + Some(Self { port, certificate, hang_up }) + } +} + +fn response(request: &str) -> String { + let id = request + .split("\"id\":") + .nth(1) + .and_then(|rest| rest.trim_start().split(|c: char| !c.is_ascii_digit()).next()) + .and_then(|digits| digits.parse::().ok()) + .unwrap_or(0); + + format!( + "{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{{\"height\":{TEST_HEIGHT},\"hex\":\"{GENESIS_HEADER}\"}}}}\n" + ) +} diff --git a/rust/src/node/client/electrum/transport.rs b/rust/src/node/client/electrum/transport.rs new file mode 100644 index 000000000..aa2f04a8a --- /dev/null +++ b/rust/src/node/client/electrum/transport.rs @@ -0,0 +1,677 @@ +use std::borrow::Borrow; +use std::net::TcpStream; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use bdk_electrum::electrum_client::raw_client::{ElectrumSslStream, RawClient}; +use bdk_electrum::electrum_client::{ + Batch, BroadcastPackageRes, Client, ElectrumApi, Error, EstimationMode, GetBalanceRes, + GetHeadersRes, GetHistoryRes, GetMerkleRes, ListUnspentRes, MempoolInfoRes, Param, + RawHeaderNotification, ScriptStatus, ServerFeaturesRes, TxidFromPosRes, +}; +use bitcoin::{Script, Txid}; +use rustls::pki_types::{CertificateDer, ServerName}; +use rustls::{CertificateError, ClientConnection, StreamOwned}; +use tracing::debug; +use url::{Host, Url}; + +use crate::node::tls::{self, TlsTrust}; + +const DEFAULT_SSL_PORT: u16 = 50002; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Bounds a peer that accepts a connection and then stalls, which would +/// otherwise hold a blocking thread for the lifetime of the process. Generous +/// enough that a slow batch response is not mistaken for a dead connection. +const IO_TIMEOUT: Duration = Duration::from_secs(60); + +/// Which Electrum client backs a connection. +/// +/// [`Client`] stays the default. A custom certificate needs a TLS session built +/// from our own [`rustls::ClientConfig`], and the only electrum-client entry +/// point that accepts one is [`RawClient`]. +pub enum Transport { + Default(Box), + Pinned(Box), +} + +impl Transport { + pub fn connect_pinned(url: &str, trust: &TlsTrust) -> Result { + Ok(Self::Pinned(Box::new(Pinned::connect(url, trust)?))) + } +} + +/// A connection with custom certificate settings, plus what is needed to +/// rebuild it. +/// +/// [`Client`] reconnects internally on failure and [`RawClient`] does not, but +/// the client is cached for the lifetime of a wallet, so without this a single +/// dropped socket would break the node until the app restarts. +pub struct Pinned { + url: String, + trust: TlsTrust, + connection: RwLock, +} + +struct Connection { + client: RawClient, + /// Bumped on every reconnect, so callers that failed on an older connection + /// can tell it has already been replaced. + generation: u64, +} + +impl Pinned { + fn connect(url: &str, trust: &TlsTrust) -> Result { + let client = connect(url, trust)?; + + Ok(Self { + url: url.to_string(), + trust: trust.clone(), + connection: RwLock::new(Connection { client, generation: 0 }), + }) + } + + /// Run `call`, reconnecting once if the connection turned out to be dead. + /// + /// Mirrors electrum-client's retry policy: a protocol error came from the + /// server and will repeat, anything else may be a broken socket. + fn call( + &self, + call: impl Fn(&RawClient) -> Result, + ) -> Result { + // Scoped so the read guard is released before the write below. + let (error, generation) = { + let connection = self.read(); + + match call(&connection.client) { + Ok(value) => return Ok(value), + Err(error @ (Error::Protocol(_) | Error::AlreadySubscribed(_))) => { + return Err(error); + } + Err(error) => (error, connection.generation), + } + }; + + debug!("electrum call failed ({error}), reconnecting to {}", self.url); + + { + let mut connection = self.write(); + + // Another caller may already have replaced the dead connection. + if connection.generation == generation { + connection.client = connect(&self.url, &self.trust) + .map_err(|error| Error::Message(error.to_string()))?; + connection.generation += 1; + } + } + + call(&self.read().client) + } + + /// A panic in one call must not wedge the node for the rest of the session, + /// so poisoning is ignored rather than propagated. + fn read(&self) -> std::sync::RwLockReadGuard<'_, Connection> { + self.connection.read().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Connection> { + self.connection.write().unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ConnectError { + #[error("custom certificates require an ssl:// url")] + NotSsl, + + #[error("node url has no host")] + MissingHost, + + #[error("invalid host name: {0}")] + InvalidHost(String), + + #[error("the server's certificate does not match this node's certificate settings: {0}")] + CertificateRejected(CertificateError), + + #[error(transparent)] + Tls(#[from] tls::Error), + + #[error("the server did not present a certificate")] + NoCertificate, + + #[error("failed to establish a TLS session: {0}")] + Session(rustls::Error), + + #[error("failed to connect: {0}")] + Connect(std::io::Error), +} + +fn connect(url: &str, trust: &TlsTrust) -> Result, ConnectError> { + let (host, port) = target(url)?; + + // A pinned fingerprint ignores this name, but rustls still requires a + // syntactically valid one to open the session. + let server_name = ServerName::try_from(host.as_str()) + .map_err(|_| ConnectError::InvalidHost(host.clone()))? + .to_owned(); + + handshake(server_name, tls::client_config(trust)?, &host, port).map(RawClient::from) +} + +/// Read the certificate a server presents, so the user can confirm it before +/// pinning it. +/// +/// Nothing about the certificate is verified here, and the connection is closed +/// without being used. The value only becomes trusted once the user accepts it. +pub(crate) fn peer_certificate(url: &str) -> Result, ConnectError> { + let (host, port) = target(url)?; + + let server_name = ServerName::try_from(host.as_str()) + .map_err(|_| ConnectError::InvalidHost(host.clone()))? + .to_owned(); + + let capture = Arc::new(tls::CapturedCertificate::default()); + handshake(server_name, tls::capture_config(capture.clone())?, &host, port)?; + + capture.take().ok_or(ConnectError::NoCertificate) +} + +/// Connect and complete the TLS handshake, so a rejected certificate is +/// reported while creating the client rather than on the first call, which is +/// what the default path already does. +fn handshake( + server_name: ServerName<'static>, + config: rustls::ClientConfig, + host: &str, + port: u16, +) -> Result { + let mut session = + ClientConnection::new(Arc::new(config), server_name).map_err(ConnectError::Session)?; + + let mut tcp = connect_timeout(host, port)?; + tcp.set_read_timeout(Some(IO_TIMEOUT)).map_err(ConnectError::Connect)?; + tcp.set_write_timeout(Some(IO_TIMEOUT)).map_err(ConnectError::Connect)?; + + session.complete_io(&mut tcp).map_err(handshake_error)?; + + Ok(StreamOwned::new(session, tcp)) +} + +/// Split an `ssl://` node url into a host that the resolver and rustls both accept. +fn target(url: &str) -> Result<(String, u16), ConnectError> { + if !url.starts_with("ssl://") { + return Err(ConnectError::NotSsl); + } + + let parsed = Url::parse(url).map_err(|_| ConnectError::InvalidHost(url.to_string()))?; + let port = parsed.port().unwrap_or(DEFAULT_SSL_PORT); + + let host = match parsed.host().ok_or(ConnectError::MissingHost)? { + // Displaying an IPv6 host keeps the brackets, which neither rustls nor + // the resolver accepts. + Host::Ipv6(ip) => ip.to_string(), + host => host.to_string(), + }; + + Ok((host, port)) +} + +/// Only a rejected certificate counts as one. Every other handshake failure, +/// such as an `ssl://` url pointed at a plaintext port, stays a connection +/// error so the user is never asked to trust their way out of it. +fn handshake_error(error: std::io::Error) -> ConnectError { + match error.get_ref().and_then(|inner| inner.downcast_ref::()) { + Some(rustls::Error::InvalidCertificate(reason)) => { + ConnectError::CertificateRejected(reason.clone()) + } + _ => ConnectError::Connect(error), + } +} + +fn connect_timeout(host: &str, port: u16) -> Result { + use std::net::ToSocketAddrs as _; + + let mut last = None; + for addr in (host, port).to_socket_addrs().map_err(ConnectError::Connect)? { + match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) { + Ok(stream) => return Ok(stream), + Err(error) => last = Some(error), + } + } + + Err(ConnectError::Connect(last.unwrap_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "no addresses for host") + }))) +} + +/// `ElectrumApi` is not object safe, since `raw_call` is generic over its +/// parameters, so the two backends are dispatched by hand. +macro_rules! dispatch { + ($self:expr, $method:ident $(, $arg:expr)*) => { + match $self { + Transport::Default(inner) => inner.$method($($arg),*), + Transport::Pinned(pinned) => pinned.call(|inner| inner.$method($($arg),*)), + } + }; +} + +impl ElectrumApi for Transport { + fn raw_call( + &self, + method_name: &str, + params: impl IntoIterator, + ) -> Result { + // Collected so that a retry can replay them. + let params = params.into_iter().collect::>(); + dispatch!(self, raw_call, method_name, params.clone()) + } + + fn batch_call(&self, batch: &Batch) -> Result, Error> { + dispatch!(self, batch_call, batch) + } + + fn block_headers_subscribe_raw(&self) -> Result { + dispatch!(self, block_headers_subscribe_raw) + } + + fn block_headers_pop_raw(&self) -> Result, Error> { + dispatch!(self, block_headers_pop_raw) + } + + fn block_header_raw(&self, height: usize) -> Result, Error> { + dispatch!(self, block_header_raw, height) + } + + fn block_headers(&self, start_height: usize, count: usize) -> Result { + dispatch!(self, block_headers, start_height, count) + } + + fn estimate_fee(&self, number: usize, mode: Option) -> Result { + dispatch!(self, estimate_fee, number, mode) + } + + fn relay_fee(&self) -> Result { + dispatch!(self, relay_fee) + } + + fn script_subscribe(&self, script: &Script) -> Result, Error> { + dispatch!(self, script_subscribe, script) + } + + fn script_unsubscribe(&self, script: &Script) -> Result { + dispatch!(self, script_unsubscribe, script) + } + + fn script_pop(&self, script: &Script) -> Result, Error> { + dispatch!(self, script_pop, script) + } + + fn script_get_balance(&self, script: &Script) -> Result { + dispatch!(self, script_get_balance, script) + } + + fn script_get_history(&self, script: &Script) -> Result, Error> { + dispatch!(self, script_get_history, script) + } + + fn script_list_unspent(&self, script: &Script) -> Result, Error> { + dispatch!(self, script_list_unspent, script) + } + + fn transaction_get_raw(&self, txid: &Txid) -> Result, Error> { + dispatch!(self, transaction_get_raw, txid) + } + + fn transaction_broadcast_raw(&self, raw_tx: &[u8]) -> Result { + dispatch!(self, transaction_broadcast_raw, raw_tx) + } + + fn transaction_get_merkle(&self, txid: &Txid, height: usize) -> Result { + dispatch!(self, transaction_get_merkle, txid, height) + } + + fn txid_from_pos(&self, height: usize, tx_pos: usize) -> Result { + dispatch!(self, txid_from_pos, height, tx_pos) + } + + fn server_features(&self) -> Result { + dispatch!(self, server_features) + } + + fn mempool_get_info(&self) -> Result { + dispatch!(self, mempool_get_info) + } + + fn ping(&self) -> Result<(), Error> { + dispatch!(self, ping) + } + + fn calls_made(&self) -> Result { + dispatch!(self, calls_made) + } + + fn batch_script_subscribe<'s, I>(&self, scripts: I) -> Result>, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow<&'s Script>, + { + dispatch!(self, batch_script_subscribe, scripts.clone()) + } + + fn batch_script_get_balance<'s, I>(&self, scripts: I) -> Result, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow<&'s Script>, + { + dispatch!(self, batch_script_get_balance, scripts.clone()) + } + + fn batch_script_get_history<'s, I>(&self, scripts: I) -> Result>, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow<&'s Script>, + { + dispatch!(self, batch_script_get_history, scripts.clone()) + } + + fn batch_script_list_unspent<'s, I>( + &self, + scripts: I, + ) -> Result>, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow<&'s Script>, + { + dispatch!(self, batch_script_list_unspent, scripts.clone()) + } + + fn batch_transaction_get_raw<'t, I>(&self, txids: I) -> Result>, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow<&'t Txid>, + { + dispatch!(self, batch_transaction_get_raw, txids.clone()) + } + + fn batch_block_header_raw(&self, heights: I) -> Result>, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow, + { + dispatch!(self, batch_block_header_raw, heights.clone()) + } + + fn batch_estimate_fee(&self, numbers: I) -> Result, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow, + { + dispatch!(self, batch_estimate_fee, numbers.clone()) + } + + fn transaction_broadcast_package_raw>( + &self, + raw_txs: &[T], + ) -> Result { + dispatch!(self, transaction_broadcast_package_raw, raw_txs) + } + + fn batch_transaction_get_merkle( + &self, + txids_and_heights: I, + ) -> Result, Error> + where + I: IntoIterator + Clone, + I::Item: Borrow<(Txid, usize)>, + { + dispatch!(self, batch_transaction_get_merkle, txids_and_heights.clone()) + } + + fn txid_from_pos_with_merkle( + &self, + height: usize, + tx_pos: usize, + ) -> Result { + dispatch!(self, txid_from_pos_with_merkle, height, tx_pos) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::client::electrum::ElectrumClient; + use crate::node::client::electrum::test_server::{ + Authority, TEST_HEIGHT, TestServer, self_signed, setup, + }; + use crate::node::{ApiType, Node}; + use cove_types::network::Network; + + fn test_node(host: &str, port: u16, trust: Option) -> Node { + Node { + name: "test".to_string(), + network: Network::Bitcoin, + api_type: ApiType::Electrum, + url: format!("ssl://{host}:{port}"), + tls: trust, + } + } + + async fn get_height(node: &Node) -> Result { + let client = ElectrumClient::new_from_node(node).await.map_err(|e| e.to_string())?; + client.get_height().await.map_err(|e| e.to_string()) + } + + #[tokio::test] + async fn pinned_fingerprint_accepts_a_self_signed_server() { + setup(); + let server = TestServer::self_signed("localhost"); + let node = test_node("localhost", server.port, Some(server.fingerprint_trust())); + + assert_eq!(get_height(&node).await.unwrap(), TEST_HEIGHT); + } + + /// The case from the bug report: a server reached by IP, with a certificate + /// that has no matching SAN. + #[tokio::test] + async fn pinned_fingerprint_ignores_a_hostname_mismatch() { + setup(); + let server = TestServer::self_signed("fulcrum.local"); + let node = test_node("127.0.0.1", server.port, Some(server.fingerprint_trust())); + + assert_eq!(get_height(&node).await.unwrap(), TEST_HEIGHT); + } + + #[tokio::test] + async fn pinned_fingerprint_connects_over_ipv6() { + setup(); + let Some(server) = TestServer::self_signed_ipv6("fulcrum.local") else { return }; + let node = test_node("[::1]", server.port, Some(server.fingerprint_trust())); + + assert_eq!(get_height(&node).await.unwrap(), TEST_HEIGHT); + } + + #[tokio::test] + async fn pinned_fingerprint_rejects_a_different_certificate() { + setup(); + let server = TestServer::self_signed("localhost"); + let other = TlsTrust::PinnedFingerprint { + sha256: tls::fingerprint(&self_signed("localhost").0).to_vec(), + }; + + let error = + get_height(&test_node("localhost", server.port, Some(other))).await.unwrap_err(); + assert!(error.contains("does not match this node's certificate settings"), "{error}"); + } + + /// A privately issued leaf must validate against its CA, which is what lets + /// the leaf rotate without the user pinning it again. + #[tokio::test] + async fn custom_ca_accepts_a_leaf_it_issued() { + setup(); + let authority = Authority::new(); + let server = TestServer::issued_by(&authority, "localhost"); + let node = test_node("localhost", server.port, Some(authority.trust())); + + assert_eq!(get_height(&node).await.unwrap(), TEST_HEIGHT); + + // A freshly issued leaf from the same CA must still be trusted. + let rotated = TestServer::issued_by(&authority, "localhost"); + let rotated = test_node("localhost", rotated.port, Some(authority.trust())); + + assert_eq!(get_height(&rotated).await.unwrap(), TEST_HEIGHT); + } + + #[tokio::test] + async fn custom_ca_rejects_a_leaf_from_another_authority() { + setup(); + let server = TestServer::issued_by(&Authority::new(), "localhost"); + let node = test_node("localhost", server.port, Some(Authority::new().trust())); + + let error = get_height(&node).await.unwrap_err(); + assert!(error.contains("UnknownIssuer"), "{error}"); + } + + /// Unlike a pinned fingerprint, a custom CA still enforces the hostname. + #[tokio::test] + async fn custom_ca_still_checks_the_hostname() { + setup(); + let authority = Authority::new(); + let server = TestServer::issued_by(&authority, "fulcrum.home.arpa"); + let node = test_node("127.0.0.1", server.port, Some(authority.trust())); + + let error = get_height(&node).await.unwrap_err(); + assert!(error.contains("not valid for name"), "{error}"); + } + + /// A node with no certificate settings must behave exactly as before. + #[tokio::test] + async fn default_trust_still_rejects_a_self_signed_server() { + setup(); + let server = TestServer::self_signed("localhost"); + + let error = get_height(&test_node("localhost", server.port, None)).await.unwrap_err(); + assert!(error.contains("UnknownIssuer"), "{error}"); + } + + /// The client is cached for the lifetime of a wallet, so it has to survive + /// the server dropping the connection. + #[tokio::test] + async fn a_pinned_connection_recovers_when_the_server_hangs_up() { + setup(); + let server = TestServer::self_signed("localhost"); + let node = test_node("localhost", server.port, Some(server.fingerprint_trust())); + + let client = ElectrumClient::new_from_node(&node).await.unwrap(); + assert_eq!(client.get_height().await.unwrap(), TEST_HEIGHT); + + server.hang_up_once(); + assert_eq!(client.get_height().await.unwrap(), TEST_HEIGHT); + assert_eq!(client.get_height().await.unwrap(), TEST_HEIGHT, "did not reconnect"); + } + + /// The UI offers to trust a certificate only for this failure, so it must + /// not be confused with a node that is simply unreachable. + #[tokio::test] + async fn a_rejected_certificate_is_reported_as_a_certificate_problem() { + setup(); + let server = TestServer::self_signed("localhost"); + + let error = test_node("localhost", server.port, None).check_url().await.unwrap_err(); + assert!(error.is_certificate_error(), "{error}"); + } + + #[tokio::test] + async fn an_unreachable_node_is_not_a_certificate_problem() { + setup(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let error = test_node("127.0.0.1", port, None).check_url().await.unwrap_err(); + assert!(!error.is_certificate_error(), "{error}"); + } + + /// The whole journey the settings screen puts a user through. + #[tokio::test] + async fn accepting_a_certificate_ends_in_a_working_connection() { + setup(); + let server = TestServer::self_signed("fulcrum.local"); + let url = format!("ssl://127.0.0.1:{}", server.port); + + // The node cannot be verified, which is what prompts the question. + let error = test_node("127.0.0.1", server.port, None).check_url().await.unwrap_err(); + assert!(error.is_certificate_error(), "{error}"); + + // The certificate is read so its fingerprint can be shown. + let certificate = peer_certificate(&url).unwrap(); + let sha256 = tls::fingerprint(&certificate); + + // Accepting it pins that certificate, and the node now connects. + let trust = TlsTrust::PinnedFingerprint { sha256: sha256.to_vec() }; + test_node("127.0.0.1", server.port, Some(trust)).check_url().await.unwrap(); + } + + /// The reconnect path takes a read lock and then a write lock, so several + /// callers hitting a dead connection at once must not deadlock. + #[tokio::test] + async fn concurrent_calls_survive_a_dropped_connection() { + setup(); + let server = TestServer::self_signed("localhost"); + let node = test_node("localhost", server.port, Some(server.fingerprint_trust())); + + let client = ElectrumClient::new_from_node(&node).await.unwrap(); + server.hang_up_once(); + + let mut calls = Vec::new(); + for _ in 0..8 { + let client = client.clone(); + calls.push(tokio::spawn(async move { client.get_height().await })); + } + + for call in calls { + assert_eq!(call.await.unwrap().unwrap(), TEST_HEIGHT); + } + } + + /// The fingerprint offered to the user has to be the server's real one. + #[tokio::test] + async fn the_certificate_read_matches_what_the_server_presents() { + setup(); + let server = TestServer::self_signed("localhost"); + + let read = peer_certificate(&format!("ssl://localhost:{}", server.port)).unwrap(); + + assert_eq!( + TlsTrust::PinnedFingerprint { sha256: tls::fingerprint(&read).to_vec() }, + server.fingerprint_trust() + ); + } + + #[tokio::test] + async fn reading_a_certificate_from_an_unreachable_host_fails() { + setup(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let error = peer_certificate(&format!("ssl://127.0.0.1:{port}")).unwrap_err(); + assert!(matches!(error, ConnectError::Connect(_)), "{error}"); + } + + #[test] + fn a_custom_certificate_needs_an_ssl_url() { + assert!(matches!(target("tcp://localhost:50001"), Err(ConnectError::NotSsl))); + } + + #[test] + fn targets_default_to_the_electrum_ssl_port() { + assert_eq!(target("ssl://node.example.com").unwrap(), ("node.example.com".into(), 50002)); + assert_eq!(target("ssl://node.example.com:993").unwrap(), ("node.example.com".into(), 993)); + } + + #[test] + fn targets_strip_ipv6_brackets() { + assert_eq!(target("ssl://[::1]:50002").unwrap(), ("::1".into(), 50002)); + } +} diff --git a/rust/src/node/tls.rs b/rust/src/node/tls.rs new file mode 100644 index 000000000..2746cf1d3 --- /dev/null +++ b/rust/src/node/tls.rs @@ -0,0 +1,289 @@ +use std::sync::Arc; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature}; +use rustls::pki_types::pem::PemObject as _; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme}; +use sha2::{Digest as _, Sha256}; + +/// How a node's TLS certificate is verified. +/// +/// A node with no [`TlsTrust`] is verified against the bundled webpki roots, +/// which is what every node did before this type existed. +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum, serde::Serialize, serde::Deserialize)] +pub enum TlsTrust { + /// Verify the chain against a user supplied CA, still checking the hostname. + /// The leaf may rotate without invalidating the setting, so this suits a + /// self hosted certificate authority. + CustomCa { cert: Vec }, + + /// Accept exactly one leaf certificate, identified by the SHA-256 of its DER + /// encoding. The hostname is not checked, so this also covers certificates + /// issued without a matching SAN, which is common for servers reached by IP. + /// + /// Expiry is not checked either: the certificate is trusted because the user + /// chose it, not because an authority vouched for it, so it stays valid until + /// they replace it. + PinnedFingerprint { sha256: Vec }, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("certificate is not valid PEM or DER")] + InvalidCertificate, + + #[error("certificate cannot be used as a trust anchor: {0}")] + UntrustedCertificate(rustls::Error), + + #[error("certificate fingerprint must be 32 bytes, got {0}")] + InvalidFingerprintLength(usize), + + #[error("failed to build TLS configuration: {0}")] + Config(rustls::Error), +} + +/// SHA-256 of a certificate's DER encoding, the value shown to the user when +/// they confirm a pin. +pub fn fingerprint(cert: &CertificateDer<'_>) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(&Sha256::digest(cert.as_ref())); + out +} + +/// Accept whichever encoding the user's server exposes. +pub fn parse_certificate(bytes: &[u8]) -> Result, Error> { + if let Ok(cert) = CertificateDer::from_pem_slice(bytes) { + return Ok(cert); + } + + // Not PEM, so treat it as raw DER. A certificate is a SEQUENCE, and rejecting + // anything else here keeps obviously wrong input from becoming a trust anchor. + if bytes.first() == Some(&0x30) { + return Ok(CertificateDer::from(bytes.to_vec())); + } + + Err(Error::InvalidCertificate) +} + +/// Build a rustls config that trusts exactly what `trust` describes and nothing else. +pub fn client_config(trust: &TlsTrust) -> Result { + // Own the provider rather than relying on the process-wide default, so the + // config is correct regardless of bootstrap order. + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .map_err(Error::Config)?; + + match trust { + TlsTrust::CustomCa { cert } => { + let mut roots = RootCertStore::empty(); + roots.add(parse_certificate(cert)?).map_err(Error::UntrustedCertificate)?; + Ok(builder.with_root_certificates(roots).with_no_client_auth()) + } + + TlsTrust::PinnedFingerprint { sha256 } => { + let expected: [u8; 32] = sha256 + .as_slice() + .try_into() + .map_err(|_| Error::InvalidFingerprintLength(sha256.len()))?; + + Ok(builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(PinnedFingerprint { + expected, + provider, + })) + .with_no_client_auth()) + } + } +} + +/// Config for reading a server's certificate without judging it. +/// +/// The certificate this accepts is only ever shown to the user so they can +/// compare it against their server; it must not be used to carry traffic. An +/// attacker in the path can present their own certificate here, which is why +/// the fingerprint has to be confirmed out of band before it is pinned. +pub(crate) fn capture_config(capture: Arc) -> Result { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + capture.provider.get_or_init(|| provider.clone()); + + Ok(ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(Error::Config)? + .dangerous() + .with_custom_certificate_verifier(capture) + .with_no_client_auth()) +} + +#[derive(Debug, Default)] +pub(crate) struct CapturedCertificate { + certificate: std::sync::Mutex>>, + provider: std::sync::OnceLock>, +} + +impl CapturedCertificate { + fn algorithms(&self) -> &rustls::crypto::WebPkiSupportedAlgorithms { + &self + .provider + .get_or_init(|| Arc::new(rustls::crypto::ring::default_provider())) + .signature_verification_algorithms + } +} + +impl CapturedCertificate { + pub(crate) fn take(&self) -> Option> { + self.certificate.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() + } +} + +impl ServerCertVerifier for CapturedCertificate { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + let mut captured = + self.certificate.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *captured = Some(end_entity.clone().into_owned()); + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls12_signature(message, cert, dss, self.algorithms()) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls13_signature(message, cert, dss, self.algorithms()) + } + + fn supported_verify_schemes(&self) -> Vec { + self.algorithms().supported_schemes() + } +} + +/// Colon separated uppercase hex, the form `openssl` and other wallets show. +pub fn display_fingerprint(sha256: &[u8]) -> String { + sha256.iter().map(|byte| format!("{byte:02X}")).collect::>().join(":") +} + +/// Verifier that trusts a single leaf certificate by fingerprint. +/// +/// Unlike disabling verification outright, the handshake signature is still +/// checked, so the peer must prove it holds the pinned certificate's key. +#[derive(Debug)] +struct PinnedFingerprint { + expected: [u8; 32], + provider: Arc, +} + +impl ServerCertVerifier for PinnedFingerprint { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + if fingerprint(end_entity) == self.expected { + return Ok(ServerCertVerified::assertion()); + } + + // Reported as a certificate rejection so the caller can tell a rejected + // certificate apart from a connection that failed for another reason. + Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider.signature_verification_algorithms.supported_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn certificate() -> rcgen::CertifiedKey { + rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap() + } + + #[test] + fn certificates_parse_from_pem_and_der() { + let generated = certificate(); + let der = generated.cert.der().to_vec(); + + assert_eq!(parse_certificate(generated.cert.pem().as_bytes()).unwrap().as_ref(), der); + assert_eq!(parse_certificate(&der).unwrap().as_ref(), der); + } + + #[test] + fn text_that_is_not_a_certificate_is_rejected() { + assert!(matches!(parse_certificate(b"hunter2"), Err(Error::InvalidCertificate))); + } + + #[test] + fn a_custom_ca_must_be_a_usable_trust_anchor() { + // Passes the DER prefix check, but is not a certificate. + let trust = TlsTrust::CustomCa { cert: vec![0x30, 0x03, 0x02, 0x01, 0x00] }; + + assert!(matches!(client_config(&trust), Err(Error::UntrustedCertificate(_)))); + } + + #[test] + fn fingerprints_display_as_colon_separated_hex() { + assert_eq!(display_fingerprint(&[0xAB, 0x01, 0xFF]), "AB:01:FF"); + } + + #[test] + fn a_fingerprint_must_be_a_sha256_digest() { + let trust = TlsTrust::PinnedFingerprint { sha256: vec![0; 31] }; + + assert!(matches!(client_config(&trust), Err(Error::InvalidFingerprintLength(31)))); + } + + #[test] + fn a_valid_certificate_builds_both_configurations() { + let generated = certificate(); + + let ca = TlsTrust::CustomCa { cert: generated.cert.der().to_vec() }; + let pin = TlsTrust::PinnedFingerprint { sha256: vec![0; 32] }; + + assert!(client_config(&ca).is_ok()); + assert!(client_config(&pin).is_ok()); + } +} From 3c7e064b75a5c71d3e0fb7285f67ba1d7753be56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sun, 19 Jul 2026 16:28:41 -0400 Subject: [PATCH 2/9] Reject custom certificate settings on Esplora nodes TlsTrust lives on Node, so it can be set for any api type, but only the Electrum client reads it. An Esplora node configured to trust a specific certificate would silently connect using the default roots instead. Honoring it here would mean building a custom reqwest client, so refuse the node rather than connect with weaker trust than it asked for. --- rust/src/node/client/esplora.rs | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/rust/src/node/client/esplora.rs b/rust/src/node/client/esplora.rs index a1568a297..a9fcc7082 100644 --- a/rust/src/node/client/esplora.rs +++ b/rust/src/node/client/esplora.rs @@ -25,12 +25,24 @@ pub struct EsploraClient { options: NodeClientOptions, } +/// Certificate settings are Electrum only. Honoring them here would mean +/// building a custom reqwest client, so refuse rather than connect with weaker +/// trust than the node asked for. +fn reject_custom_certificates(node: &Node) -> Result<(), Error> { + match node.tls { + Some(_) => Err(Error::TlsTrustUnsupported(node.api_type)), + None => Ok(()), + } +} + impl EsploraClient { pub const fn new(client: Arc) -> Self { Self { client, options: NodeClientOptions { batch_size: ESPLORA_BATCH_SIZE } } } pub fn new_from_node(node: &Node) -> Result { + reject_custom_certificates(node)?; + let client = esplora_client::Builder::new(&node.url) .build_async() .map_err(Error::CreateEsploraClient)? @@ -43,6 +55,8 @@ impl EsploraClient { node: &Node, options: NodeClientOptions, ) -> Result { + reject_custom_certificates(node)?; + let client = esplora_client::Builder::new(&node.url) .build_async() .map_err(Error::CreateEsploraClient)? @@ -139,3 +153,33 @@ impl EsploraClient { Ok(stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::tls::TlsTrust; + use crate::node::{ApiType, Node}; + use cove_types::network::Network; + + /// Certificate settings must never be silently dropped: a node configured to + /// trust one certificate would otherwise connect using the default roots. + #[test] + fn esplora_refuses_custom_certificate_settings() { + let node = Node { + name: "test".to_string(), + network: Network::Bitcoin, + api_type: ApiType::Esplora, + url: "https://esplora.example.com".to_string(), + tls: Some(TlsTrust::PinnedFingerprint { sha256: vec![0; 32] }), + }; + + assert!(matches!( + EsploraClient::new_from_node(&node), + Err(Error::TlsTrustUnsupported(ApiType::Esplora)) + )); + assert!(matches!( + EsploraClient::new_from_node_and_options(&node, NodeClientOptions { batch_size: 1 }), + Err(Error::TlsTrustUnsupported(ApiType::Esplora)) + )); + } +} From 314c84575860687a7763794cfdb2a11823b15c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sun, 19 Jul 2026 17:07:45 -0400 Subject: [PATCH 3/9] Let a custom node carry certificate settings The Electrum client honored TlsTrust, but nothing could set it: custom nodes were always built with no certificate settings, so the feature was unreachable. parse_custom_node now takes them, and fetch_node_certificate reads what a server presents so it can be shown to the user before it is trusted. That read verifies nothing, which is why the fingerprint has to be confirmed against the server rather than accepted on sight. certificate_decision answers whether a rejected certificate can be offered for confirmation at all. A url that already trusts a certificate is told the certificate changed rather than asked to accept a new one. Deciding it here rather than in each app keeps the rule in one place and lets it be tested; check_and_save_node also refuses to save a node that would drop the certificate a url already trusts, so it cannot be lost by omission either. Hosts are allowed to be IP addresses. Self hosted servers are often reached that way, and an IPv6 literal was rejected outright. --- rust/src/node_connect.rs | 255 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 252 insertions(+), 3 deletions(-) diff --git a/rust/src/node_connect.rs b/rust/src/node_connect.rs index fdc41d462..a8a82c852 100644 --- a/rust/src/node_connect.rs +++ b/rust/src/node_connect.rs @@ -1,6 +1,8 @@ use tracing::error; use url::Url; +use crate::node::client::electrum::transport; +use crate::node::tls::{self, TlsTrust}; use crate::{database::Database, network::Network, node::Node}; use cove_macros::impl_default_for; use eyre::{Context, eyre}; @@ -60,6 +62,39 @@ pub enum NodeSelectorError { #[error("unable to parse node url: {0}")] ParseNodeUrlError(String), + + #[error("unable to read the server's certificate: {0}")] + ReadCertificateError(String), + + #[error("the server's certificate is not trusted")] + CertificateNotTrusted, + + #[error("saving this node would forget the certificate it trusts")] + CertificateWouldBeForgotten, +} + +/// What to do about a node whose certificate was rejected. +#[derive(Debug, Clone, uniffi::Enum, PartialEq, Eq, Hash)] +pub enum CertificateDecision { + /// Nothing is trusted for this url yet, so the certificate can be offered + /// for the user to accept. + Unrecognized { certificate: NodeCertificate }, + + /// This url already trusts a different certificate. Offering to accept the + /// new one would undo the decision the user already made, so it is reported + /// rather than asked about. + Changed, +} + +/// A certificate a server presented, offered to the user for confirmation. +#[derive(Debug, Clone, uniffi::Record, PartialEq, Eq, Hash)] +pub struct NodeCertificate { + /// SHA-256 of the certificate, ready to store as [`TlsTrust`]. + pub sha256: Vec, + + /// The same value as colon separated hex, so it can be compared against + /// what the server operator sees. + pub display: String, } impl_default_for!(NodeSelector); @@ -130,20 +165,21 @@ impl NodeSelector { Ok(()) } - #[uniffi::method] + #[uniffi::method(default(tls = None))] /// Use the url and name of the custom node to set it as the selected node pub fn parse_custom_node( &self, url: String, name: String, entered_name: String, + tls: Option, ) -> Result { let node_type = name.to_ascii_lowercase(); let url = parse_node_url(&url).map_err(|error| Error::ParseNodeUrlError(error.to_string()))?; - if !url.domain().unwrap_or_default().contains('.') { + if !has_usable_host(&url) { return Err(Error::ParseNodeUrlError("invalid url, no domain".to_string())); } @@ -156,8 +192,24 @@ impl NodeSelector { }; let node = if node_type.contains("electrum") { - Node::new_electrum(name, url_string, self.network) + // Only an ssl:// node can present a certificate, and failing here + // says so rather than leaving it to a generic connection error. + if tls.is_some() && !url_string.starts_with("ssl://") { + return Err(Error::ParseNodeUrlError( + "custom certificates require an ssl:// url".to_string(), + )); + } + + Node { tls, ..Node::new_electrum(name, url_string, self.network) } } else if node_type.contains("esplora") { + // Silently dropping the setting here would contradict the Esplora + // client, which refuses a node it cannot honor. + if tls.is_some() { + return Err(Error::ParseNodeUrlError( + "esplora nodes do not support custom certificate settings".to_string(), + )); + } + Node::new_esplora(name, url_string, self.network) } else { error!("invalid node type: {node_type}"); @@ -167,11 +219,59 @@ impl NodeSelector { Ok(node) } + #[uniffi::method] + /// Decide what a rejected certificate means for this url. + /// + /// Deciding here rather than in each app keeps one rule: a url that already + /// trusts a certificate is never offered a different one. + pub async fn certificate_decision(&self, url: String) -> Result { + let url = normalized_url(&url)?; + + if self.trusted_certificate(&url).is_some() { + return Ok(CertificateDecision::Changed); + } + + let certificate = self.fetch_node_certificate(url).await?; + Ok(CertificateDecision::Unrecognized { certificate }) + } + + #[uniffi::method] + /// Read the certificate a server presents, so it can be shown to the user. + /// + /// The certificate is not verified. It is only trusted once the user has + /// compared the fingerprint against their server and accepted it. + pub async fn fetch_node_certificate(&self, url: String) -> Result { + let url = normalized_url(&url)?; + + let certificate = + cove_tokio::unblock::run_blocking(move || transport::peer_certificate(&url)) + .await + .map_err(|error| Error::ReadCertificateError(error.to_string()))?; + + let sha256 = tls::fingerprint(&certificate); + + Ok(NodeCertificate { sha256: sha256.to_vec(), display: tls::display_fingerprint(&sha256) }) + } + #[uniffi::method] /// Check the node url and set it as selected node if it is valid pub async fn check_and_save_node(&self, node: Node) -> Result<(), Error> { + // A caller that forgets to carry the settings forward would otherwise + // quietly drop the certificate the user chose to trust. + let saved = Database::global().global_config.selected_node(); + if would_forget_certificate(&saved, &node) { + return Err(Error::CertificateWouldBeForgotten); + } + node.check_url().await.map_err(|error| { tracing::warn!("error checking node: {error:?}"); + + // Distinguished so the caller can offer to trust the certificate + // instead of showing a generic failure. + if error.is_certificate_error() { + return Error::CertificateNotTrusted; + } + Error::NodeAccessError(error.to_string()) })?; @@ -238,6 +338,41 @@ fn node_list(network: Network) -> Vec { } } +impl NodeSelector { + fn trusted_certificate(&self, url: &str) -> Option { + trusted_certificate(&Database::global().global_config.selected_node(), url) + } +} + +/// The certificate `saved` trusts for `url`, which is only its own certificate +/// settings and only when it is the same url. +fn trusted_certificate(saved: &Node, url: &str) -> Option { + (saved.url == url).then(|| saved.tls.clone()).flatten() +} + +/// Whether saving `node` would drop a certificate `saved` already trusts. +fn would_forget_certificate(saved: &Node, node: &Node) -> bool { + node.tls.is_none() && trusted_certificate(saved, &node.url).is_some() +} + +fn normalized_url(url: &str) -> Result { + let url = parse_node_url(url) + .map_err(|error| Error::ParseNodeUrlError(error.to_string()))? + .to_string(); + + Ok(url.strip_suffix('/').unwrap_or(&url).to_string()) +} + +/// A url is usable when it names a host we can actually reach: a dotted domain +/// or a literal IP address, which is how self hosted servers are often reached. +fn has_usable_host(url: &Url) -> bool { + match url.host() { + Some(url::Host::Domain(domain)) => domain.contains('.'), + Some(_) => true, + None => false, + } +} + fn parse_node_url(url: &str) -> eyre::Result { let url = url.replace("http://", "tcp://"); let url = url.replace("https://", "ssl://"); @@ -310,3 +445,117 @@ fn default_node_selection() -> NodeSelection { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn selector() -> NodeSelector { + NodeSelector { network: Network::Bitcoin, node_list: Vec::new() } + } + + fn parse(url: &str) -> Result { + selector().parse_custom_node( + url.to_string(), + "Custom Electrum".to_string(), + String::new(), + None, + ) + } + + #[test] + fn custom_nodes_keep_their_certificate_settings() { + let trust = TlsTrust::PinnedFingerprint { sha256: vec![3; 32] }; + + let node = selector() + .parse_custom_node( + "ssl://node.example.com:50002".to_string(), + "Custom Electrum".to_string(), + String::new(), + Some(trust.clone()), + ) + .unwrap(); + + assert_eq!(node.tls, Some(trust)); + } + + /// Self hosted servers are commonly reached by address rather than by name. + #[test] + fn nodes_can_be_reached_by_ip_address() { + assert_eq!(parse("ssl://192.168.1.50:50002").unwrap().url, "ssl://192.168.1.50:50002"); + assert_eq!(parse("ssl://[fd00::1]:50002").unwrap().url, "ssl://[fd00::1]:50002"); + } + + #[test] + fn esplora_nodes_reject_certificate_settings() { + let error = selector() + .parse_custom_node( + "https://esplora.example.com".to_string(), + "Custom Esplora".to_string(), + String::new(), + Some(TlsTrust::PinnedFingerprint { sha256: vec![1; 32] }), + ) + .unwrap_err(); + + assert!(matches!(error, Error::ParseNodeUrlError(_)), "{error}"); + } + + #[test] + fn certificate_settings_require_an_ssl_url() { + let error = selector() + .parse_custom_node( + "tcp://node.example.com:50001".to_string(), + "Custom Electrum".to_string(), + String::new(), + Some(TlsTrust::PinnedFingerprint { sha256: vec![1; 32] }), + ) + .unwrap_err(); + + assert!(matches!(error, Error::ParseNodeUrlError(_)), "{error}"); + } + + fn pinned(url: &str) -> Node { + Node { + tls: Some(TlsTrust::PinnedFingerprint { sha256: vec![4; 32] }), + ..Node::new_electrum("saved".to_string(), url.to_string(), Network::Bitcoin) + } + } + + #[test] + fn a_certificate_is_only_trusted_for_the_url_it_was_accepted_for() { + let saved = pinned("ssl://node.example.com:50002"); + + assert!(trusted_certificate(&saved, "ssl://node.example.com:50002").is_some()); + assert!(trusted_certificate(&saved, "ssl://other.example.com:50002").is_none()); + } + + #[test] + fn a_node_without_a_certificate_trusts_nothing() { + let saved = Node::default(Network::Bitcoin); + + assert!(trusted_certificate(&saved, &saved.url).is_none()); + } + + /// Dropping the settings on the way in is how the trust prompt turned into a + /// question asked on every save. + #[test] + fn saving_a_node_may_not_forget_the_certificate_it_trusts() { + let saved = pinned("ssl://node.example.com:50002"); + + let forgetful = Node { tls: None, ..saved.clone() }; + assert!(would_forget_certificate(&saved, &forgetful)); + + assert!(!would_forget_certificate(&saved, &saved)); + assert!(!would_forget_certificate(&Node::default(Network::Bitcoin), &forgetful)); + + // A different url has its own trust, so it is not being forgotten. + let elsewhere = + Node { tls: None, url: "ssl://other.example.com:50002".to_string(), ..saved.clone() }; + assert!(!would_forget_certificate(&saved, &elsewhere)); + } + + #[test] + fn a_url_without_a_host_is_rejected() { + assert!(parse("ssl://nodomain:50002").is_err()); + } +} From fe540eab2bdef84cc9d2ed24b8418ea819739816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sun, 19 Jul 2026 17:07:46 -0400 Subject: [PATCH 4/9] Regenerate UniFFI bindings --- .../java/org/bitcoinppl/cove_core/cove.kt | 427 +++++++++++++++++- .../Sources/CoveCore/generated/cove.swift | 383 +++++++++++++++- 2 files changed, 798 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index e569170c8..1d385246f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1739,10 +1739,14 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_mnemonic_words( ): Short + external fun uniffi_cove_checksum_method_nodeselector_certificate_decision( + ): Short external fun uniffi_cove_checksum_method_nodeselector_check_and_save_node( ): Short external fun uniffi_cove_checksum_method_nodeselector_check_selected_node( ): Short + external fun uniffi_cove_checksum_method_nodeselector_fetch_node_certificate( + ): Short external fun uniffi_cove_checksum_method_nodeselector_node_list( ): Short external fun uniffi_cove_checksum_method_nodeselector_parse_custom_node( @@ -2912,13 +2916,17 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_constructor_nodeselector_new(uniffi_out_err: UniffiRustCallStatus, ): Long + external fun uniffi_cove_fn_method_nodeselector_certificate_decision(`ptr`: Long,`url`: RustBuffer.ByValue, + ): Long external fun uniffi_cove_fn_method_nodeselector_check_and_save_node(`ptr`: Long,`node`: RustBuffer.ByValue, ): Long external fun uniffi_cove_fn_method_nodeselector_check_selected_node(`ptr`: Long,`node`: RustBuffer.ByValue, ): Long + external fun uniffi_cove_fn_method_nodeselector_fetch_node_certificate(`ptr`: Long,`url`: RustBuffer.ByValue, + ): Long external fun uniffi_cove_fn_method_nodeselector_node_list(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_cove_fn_method_nodeselector_parse_custom_node(`ptr`: Long,`url`: RustBuffer.ByValue,`name`: RustBuffer.ByValue,`enteredName`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + external fun uniffi_cove_fn_method_nodeselector_parse_custom_node(`ptr`: Long,`url`: RustBuffer.ByValue,`name`: RustBuffer.ByValue,`enteredName`: RustBuffer.ByValue,`tls`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_nodeselector_select_preset_node(`ptr`: Long,`name`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue @@ -4761,16 +4769,22 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_mnemonic_words() != 8009.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_nodeselector_certificate_decision() != 17478.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_nodeselector_check_and_save_node() != 42980.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_nodeselector_check_selected_node() != 34244.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_nodeselector_fetch_node_certificate() != 27543.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_nodeselector_node_list() != 26686.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_nodeselector_parse_custom_node() != 26788.toShort()) { + if (lib.uniffi_cove_checksum_method_nodeselector_parse_custom_node() != 15006.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_nodeselector_select_preset_node() != 55812.toShort()) { @@ -16314,6 +16328,14 @@ public object FfiConverterTypeMnemonic: FfiConverter { public interface NodeSelectorInterface { + /** + * Decide what a rejected certificate means for this url. + * + * Deciding here rather than in each app keeps one rule: a url that already + * trusts a certificate is never offered a different one. + */ + suspend fun `certificateDecision`(`url`: kotlin.String): CertificateDecision + /** * Check the node url and set it as selected node if it is valid */ @@ -16321,12 +16343,20 @@ public interface NodeSelectorInterface { suspend fun `checkSelectedNode`(`node`: Node) + /** + * Read the certificate a server presents, so it can be shown to the user. + * + * The certificate is not verified. It is only trusted once the user has + * compared the fingerprint against their server and accepted it. + */ + suspend fun `fetchNodeCertificate`(`url`: kotlin.String): NodeCertificate + fun `nodeList`(): List /** * Use the url and name of the custom node to set it as the selected node */ - fun `parseCustomNode`(`url`: kotlin.String, `name`: kotlin.String, `enteredName`: kotlin.String): Node + fun `parseCustomNode`(`url`: kotlin.String, `name`: kotlin.String, `enteredName`: kotlin.String, `tls`: TlsTrust? = null): Node fun `selectPresetNode`(`name`: kotlin.String): Node @@ -16445,6 +16475,34 @@ open class NodeSelector: Disposable, AutoCloseable, NodeSelectorInterface } + /** + * Decide what a rejected certificate means for this url. + * + * Deciding here rather than in each app keeps one rule: a url that already + * trusts a certificate is never offered a different one. + */ + @Throws(NodeSelectorException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `certificateDecision`(`url`: kotlin.String) : CertificateDecision { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_nodeselector_certificate_decision( + uniffiHandle, + + FfiConverterString.lower(`url`), + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterTypeCertificateDecision.lift(it) }, + // Error FFI converter + NodeSelectorException.ErrorHandler, + ) + } + + /** * Check the node url and set it as selected node if it is valid */ @@ -16493,6 +16551,34 @@ open class NodeSelector: Disposable, AutoCloseable, NodeSelectorInterface ) } + + /** + * Read the certificate a server presents, so it can be shown to the user. + * + * The certificate is not verified. It is only trusted once the user has + * compared the fingerprint against their server and accepted it. + */ + @Throws(NodeSelectorException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `fetchNodeCertificate`(`url`: kotlin.String) : NodeCertificate { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_nodeselector_fetch_node_certificate( + uniffiHandle, + + FfiConverterString.lower(`url`), + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterTypeNodeCertificate.lift(it) }, + // Error FFI converter + NodeSelectorException.ErrorHandler, + ) + } + override fun `nodeList`(): List { return FfiConverterSequenceTypeNodeSelection.lift( callWithHandle { @@ -16510,7 +16596,7 @@ open class NodeSelector: Disposable, AutoCloseable, NodeSelectorInterface /** * Use the url and name of the custom node to set it as the selected node */ - @Throws(NodeSelectorException::class)override fun `parseCustomNode`(`url`: kotlin.String, `name`: kotlin.String, `enteredName`: kotlin.String): Node { + @Throws(NodeSelectorException::class)override fun `parseCustomNode`(`url`: kotlin.String, `name`: kotlin.String, `enteredName`: kotlin.String, `tls`: TlsTrust?): Node { return FfiConverterTypeNode.lift( callWithHandle { uniffiRustCallWithError(NodeSelectorException) { _status -> @@ -16519,7 +16605,8 @@ open class NodeSelector: Disposable, AutoCloseable, NodeSelectorInterface FfiConverterString.lower(`url`), FfiConverterString.lower(`name`), - FfiConverterString.lower(`enteredName`),_status) + FfiConverterString.lower(`enteredName`), + FfiConverterOptionalTypeTlsTrust.lower(`tls`),_status) } } ) @@ -30878,6 +30965,12 @@ data class Node ( var `apiType`: ApiType , var `url`: kotlin.String + , + /** + * How the node's TLS certificate is verified. `None` uses the bundled + * webpki roots, which is the behavior every node had before this field. + */ + var `tls`: TlsTrust? = null ){ @@ -30898,6 +30991,7 @@ public object FfiConverterTypeNode: FfiConverterRustBuffer { FfiConverterTypeNetwork.read(buf), FfiConverterTypeApiType.read(buf), FfiConverterString.read(buf), + FfiConverterOptionalTypeTlsTrust.read(buf), ) } @@ -30905,7 +30999,8 @@ public object FfiConverterTypeNode: FfiConverterRustBuffer { FfiConverterString.allocationSize(value.`name`) + FfiConverterTypeNetwork.allocationSize(value.`network`) + FfiConverterTypeApiType.allocationSize(value.`apiType`) + - FfiConverterString.allocationSize(value.`url`) + FfiConverterString.allocationSize(value.`url`) + + FfiConverterOptionalTypeTlsTrust.allocationSize(value.`tls`) ) override fun write(value: Node, buf: ByteBuffer) { @@ -30913,6 +31008,55 @@ public object FfiConverterTypeNode: FfiConverterRustBuffer { FfiConverterTypeNetwork.write(value.`network`, buf) FfiConverterTypeApiType.write(value.`apiType`, buf) FfiConverterString.write(value.`url`, buf) + FfiConverterOptionalTypeTlsTrust.write(value.`tls`, buf) + } +} + + + +/** + * A certificate a server presented, offered to the user for confirmation. + */ +data class NodeCertificate ( + /** + * SHA-256 of the certificate, ready to store as [`TlsTrust`]. + */ + var `sha256`: kotlin.ByteArray + , + /** + * The same value as colon separated hex, so it can be compared against + * what the server operator sees. + */ + var `display`: kotlin.String + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeNodeCertificate: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): NodeCertificate { + return NodeCertificate( + FfiConverterByteArray.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: NodeCertificate) = ( + FfiConverterByteArray.allocationSize(value.`sha256`) + + FfiConverterString.allocationSize(value.`display`) + ) + + override fun write(value: NodeCertificate, buf: ByteBuffer) { + FfiConverterByteArray.write(value.`sha256`, buf) + FfiConverterString.write(value.`display`, buf) } } @@ -36026,6 +36170,91 @@ public object FfiConverterTypeCatastrophicRecoveryError : FfiConverterRustBuffer +/** + * What to do about a node whose certificate was rejected. + */ +sealed class CertificateDecision { + + /** + * Nothing is trusted for this url yet, so the certificate can be offered + * for the user to accept. + */ + data class Unrecognized( + val `certificate`: org.bitcoinppl.cove_core.NodeCertificate) : CertificateDecision() + + { + + + companion object + } + + /** + * This url already trusts a different certificate. Offering to accept the + * new one would undo the decision the user already made, so it is reported + * rather than asked about. + */ + object Changed : CertificateDecision() + + + + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeCertificateDecision : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): CertificateDecision { + return when(buf.getInt()) { + 1 -> CertificateDecision.Unrecognized( + FfiConverterTypeNodeCertificate.read(buf), + ) + 2 -> CertificateDecision.Changed + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: CertificateDecision): ULong = when(value) { + is CertificateDecision.Unrecognized -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeNodeCertificate.allocationSize(value.`certificate`) + ) + } + is CertificateDecision.Changed -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + } + + override fun write(value: CertificateDecision, buf: ByteBuffer) { + when(value) { + is CertificateDecision.Unrecognized -> { + buf.putInt(1) + FfiConverterTypeNodeCertificate.write(value.`certificate`, buf) + Unit + } + is CertificateDecision.Changed -> { + buf.putInt(2) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + sealed class CkTapException: kotlin.Exception() { @@ -45121,6 +45350,26 @@ sealed class NodeSelectorException: kotlin.Exception() { get() = "v1=${ v1 }" } + class ReadCertificateException( + + val v1: kotlin.String + ) : NodeSelectorException() { + override val message + get() = "v1=${ v1 }" + } + + class CertificateNotTrusted( + ) : NodeSelectorException() { + override val message + get() = "" + } + + class CertificateWouldBeForgotten( + ) : NodeSelectorException() { + override val message + get() = "" + } + @@ -45152,6 +45401,11 @@ public object FfiConverterTypeNodeSelectorError : FfiConverterRustBuffer NodeSelectorException.ParseNodeUrlException( FfiConverterString.read(buf), ) + 5 -> NodeSelectorException.ReadCertificateException( + FfiConverterString.read(buf), + ) + 6 -> NodeSelectorException.CertificateNotTrusted() + 7 -> NodeSelectorException.CertificateWouldBeForgotten() else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -45178,6 +45432,19 @@ public object FfiConverterTypeNodeSelectorError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NodeSelectorException.CertificateNotTrusted -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeSelectorException.CertificateWouldBeForgotten -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) } } @@ -45203,6 +45470,19 @@ public object FfiConverterTypeNodeSelectorError : FfiConverterRustBuffer { + buf.putInt(5) + FfiConverterString.write(value.v1, buf) + Unit + } + is NodeSelectorException.CertificateNotTrusted -> { + buf.putInt(6) + Unit + } + is NodeSelectorException.CertificateWouldBeForgotten -> { + buf.putInt(7) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -52372,6 +52652,109 @@ public object FfiConverterTypeTapSignerRoute : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): TlsTrust { + return when(buf.getInt()) { + 1 -> TlsTrust.CustomCa( + FfiConverterByteArray.read(buf), + ) + 2 -> TlsTrust.PinnedFingerprint( + FfiConverterByteArray.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: TlsTrust): ULong = when(value) { + is TlsTrust.CustomCa -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterByteArray.allocationSize(value.`cert`) + ) + } + is TlsTrust.PinnedFingerprint -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterByteArray.allocationSize(value.`sha256`) + ) + } + } + + override fun write(value: TlsTrust, buf: ByteBuffer) { + when(value) { + is TlsTrust.CustomCa -> { + buf.putInt(1) + FfiConverterByteArray.write(value.`cert`, buf) + Unit + } + is TlsTrust.PinnedFingerprint -> { + buf.putInt(2) + FfiConverterByteArray.write(value.`sha256`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + sealed class Transaction: Disposable { data class Confirmed( @@ -59225,6 +59608,38 @@ public object FfiConverterOptionalTypeTapSignerResponse: FfiConverterRustBuffer< +/** + * @suppress + */ +public object FfiConverterOptionalTypeTlsTrust: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): TlsTrust? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeTlsTrust.read(buf) + } + + override fun allocationSize(value: TlsTrust?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeTlsTrust.allocationSize(value) + } + } + + override fun write(value: TlsTrust?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeTlsTrust.write(value, buf) + } + } +} + + + + /** * @suppress */ diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 861901fdb..7da727f35 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -6159,6 +6159,14 @@ public func FfiConverterTypeMnemonic_lower(_ value: Mnemonic) -> UInt64 { public protocol NodeSelectorProtocol: AnyObject, Sendable { + /** + * Decide what a rejected certificate means for this url. + * + * Deciding here rather than in each app keeps one rule: a url that already + * trusts a certificate is never offered a different one. + */ + func certificateDecision(url: String) async throws -> CertificateDecision + /** * Check the node url and set it as selected node if it is valid */ @@ -6166,12 +6174,20 @@ public protocol NodeSelectorProtocol: AnyObject, Sendable { func checkSelectedNode(node: Node) async throws + /** + * Read the certificate a server presents, so it can be shown to the user. + * + * The certificate is not verified. It is only trusted once the user has + * compared the fingerprint against their server and accepted it. + */ + func fetchNodeCertificate(url: String) async throws -> NodeCertificate + func nodeList() -> [NodeSelection] /** * Use the url and name of the custom node to set it as the selected node */ - func parseCustomNode(url: String, name: String, enteredName: String) throws -> Node + func parseCustomNode(url: String, name: String, enteredName: String, tls: TlsTrust?) throws -> Node func selectPresetNode(name: String) throws -> Node @@ -6239,6 +6255,29 @@ public convenience init() { + /** + * Decide what a rejected certificate means for this url. + * + * Deciding here rather than in each app keeps one rule: a url that already + * trusts a certificate is never offered a different one. + */ +open func certificateDecision(url: String)async throws -> CertificateDecision { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_nodeselector_certificate_decision( + self.uniffiCloneHandle(), + FfiConverterString.lower(url) + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeCertificateDecision_lift, + errorHandler: FfiConverterTypeNodeSelectorError_lift + ) +} + /** * Check the node url and set it as selected node if it is valid */ @@ -6276,6 +6315,29 @@ open func checkSelectedNode(node: Node)async throws { ) } + /** + * Read the certificate a server presents, so it can be shown to the user. + * + * The certificate is not verified. It is only trusted once the user has + * compared the fingerprint against their server and accepted it. + */ +open func fetchNodeCertificate(url: String)async throws -> NodeCertificate { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_nodeselector_fetch_node_certificate( + self.uniffiCloneHandle(), + FfiConverterString.lower(url) + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeNodeCertificate_lift, + errorHandler: FfiConverterTypeNodeSelectorError_lift + ) +} + open func nodeList() -> [NodeSelection] { return try! FfiConverterSequenceTypeNodeSelection.lift(try! rustCall() { uniffiCallStatus in @@ -6288,14 +6350,15 @@ open func nodeList() -> [NodeSelection] { /** * Use the url and name of the custom node to set it as the selected node */ -open func parseCustomNode(url: String, name: String, enteredName: String)throws -> Node { +open func parseCustomNode(url: String, name: String, enteredName: String, tls: TlsTrust? = nil)throws -> Node { return try FfiConverterTypeNode_lift(try rustCallWithError(FfiConverterTypeNodeSelectorError_lift) { uniffiCallStatus in uniffi_cove_fn_method_nodeselector_parse_custom_node( self.uniffiCloneHandle(), FfiConverterString.lower(url), FfiConverterString.lower(name), - FfiConverterString.lower(enteredName),uniffiCallStatus + FfiConverterString.lower(enteredName), + FfiConverterOptionTypeTlsTrust.lower(tls),uniffiCallStatus ) }) } @@ -16023,14 +16086,24 @@ public struct Node: Equatable, Hashable { public var network: Network public var apiType: ApiType public var url: String + /** + * How the node's TLS certificate is verified. `None` uses the bundled + * webpki roots, which is the behavior every node had before this field. + */ + public var tls: TlsTrust? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(name: String, network: Network, apiType: ApiType, url: String) { + public init(name: String, network: Network, apiType: ApiType, url: String, + /** + * How the node's TLS certificate is verified. `None` uses the bundled + * webpki roots, which is the behavior every node had before this field. + */tls: TlsTrust? = nil) { self.name = name self.network = network self.apiType = apiType self.url = url + self.tls = tls } @@ -16052,7 +16125,8 @@ public struct FfiConverterTypeNode: FfiConverterRustBuffer { name: FfiConverterString.read(from: &buf), network: FfiConverterTypeNetwork.read(from: &buf), apiType: FfiConverterTypeApiType.read(from: &buf), - url: FfiConverterString.read(from: &buf) + url: FfiConverterString.read(from: &buf), + tls: FfiConverterOptionTypeTlsTrust.read(from: &buf) ) } @@ -16061,6 +16135,7 @@ public struct FfiConverterTypeNode: FfiConverterRustBuffer { FfiConverterTypeNetwork.write(value.network, into: &buf) FfiConverterTypeApiType.write(value.apiType, into: &buf) FfiConverterString.write(value.url, into: &buf) + FfiConverterOptionTypeTlsTrust.write(value.tls, into: &buf) } } @@ -16080,6 +16155,77 @@ public func FfiConverterTypeNode_lower(_ value: Node) -> RustBuffer { } +/** + * A certificate a server presented, offered to the user for confirmation. + */ +public struct NodeCertificate: Equatable, Hashable { + /** + * SHA-256 of the certificate, ready to store as [`TlsTrust`]. + */ + public var sha256: Data + /** + * The same value as colon separated hex, so it can be compared against + * what the server operator sees. + */ + public var display: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * SHA-256 of the certificate, ready to store as [`TlsTrust`]. + */sha256: Data, + /** + * The same value as colon separated hex, so it can be compared against + * what the server operator sees. + */display: String) { + self.sha256 = sha256 + self.display = display + } + + + + +} + +#if compiler(>=6) +extension NodeCertificate: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNodeCertificate: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NodeCertificate { + return + try NodeCertificate( + sha256: FfiConverterData.read(from: &buf), + display: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: NodeCertificate, into buf: inout [UInt8]) { + FfiConverterData.write(value.sha256, into: &buf) + FfiConverterString.write(value.display, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNodeCertificate_lift(_ buf: RustBuffer) throws -> NodeCertificate { + return try FfiConverterTypeNodeCertificate.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNodeCertificate_lower(_ value: NodeCertificate) -> RustBuffer { + return FfiConverterTypeNodeCertificate.lower(value) +} + + public struct OnboardingState: Equatable, Hashable { public var step: OnboardingStep public var branch: OnboardingBranch? @@ -20385,6 +20531,87 @@ public func FfiConverterTypeCatastrophicRecoveryError_lower(_ value: Catastrophi } +/** + * What to do about a node whose certificate was rejected. + */ + +public enum CertificateDecision: Equatable, Hashable { + + /** + * Nothing is trusted for this url yet, so the certificate can be offered + * for the user to accept. + */ + case unrecognized(certificate: NodeCertificate + ) + /** + * This url already trusts a different certificate. Offering to accept the + * new one would undo the decision the user already made, so it is reported + * rather than asked about. + */ + case changed + + + + + +} + +#if compiler(>=6) +extension CertificateDecision: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCertificateDecision: FfiConverterRustBuffer { + typealias SwiftType = CertificateDecision + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CertificateDecision { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .unrecognized(certificate: try FfiConverterTypeNodeCertificate.read(from: &buf) + ) + + case 2: return .changed + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: CertificateDecision, into buf: inout [UInt8]) { + switch value { + + + case let .unrecognized(certificate): + writeInt(&buf, Int32(1)) + FfiConverterTypeNodeCertificate.write(certificate, into: &buf) + + + case .changed: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCertificateDecision_lift(_ buf: RustBuffer) throws -> CertificateDecision { + return try FfiConverterTypeCertificateDecision.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCertificateDecision_lower(_ value: CertificateDecision) -> RustBuffer { + return FfiConverterTypeCertificateDecision.lower(value) +} + + + public enum CkTapError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { @@ -27704,6 +27931,10 @@ enum NodeSelectorError: Swift.Error, Equatable, Hashable, Foundation.LocalizedEr ) case ParseNodeUrlError(String ) + case ReadCertificateError(String + ) + case CertificateNotTrusted + case CertificateWouldBeForgotten @@ -27745,6 +27976,11 @@ public struct FfiConverterTypeNodeSelectorError: FfiConverterRustBuffer { case 4: return .ParseNodeUrlError( try FfiConverterString.read(from: &buf) ) + case 5: return .ReadCertificateError( + try FfiConverterString.read(from: &buf) + ) + case 6: return .CertificateNotTrusted + case 7: return .CertificateWouldBeForgotten default: throw UniffiInternalError.unexpectedEnumCase } @@ -27776,6 +28012,19 @@ public struct FfiConverterTypeNodeSelectorError: FfiConverterRustBuffer { writeInt(&buf, Int32(4)) FfiConverterString.write(v1, into: &buf) + + case let .ReadCertificateError(v1): + writeInt(&buf, Int32(5)) + FfiConverterString.write(v1, into: &buf) + + + case .CertificateNotTrusted: + writeInt(&buf, Int32(6)) + + + case .CertificateWouldBeForgotten: + writeInt(&buf, Int32(7)) + } } } @@ -33192,6 +33441,98 @@ public func FfiConverterTypeTapSignerRoute_lower(_ value: TapSignerRoute) -> Rus +/** + * How a node's TLS certificate is verified. + * + * A node with no [`TlsTrust`] is verified against the bundled webpki roots, + * which is what every node did before this type existed. + */ + +public enum TlsTrust: Equatable, Hashable { + + /** + * Verify the chain against a user supplied CA, still checking the hostname. + * The leaf may rotate without invalidating the setting, so this suits a + * self hosted certificate authority. + */ + case customCa(cert: Data + ) + /** + * Accept exactly one leaf certificate, identified by the SHA-256 of its DER + * encoding. The hostname is not checked, so this also covers certificates + * issued without a matching SAN, which is common for servers reached by IP. + * + * Expiry is not checked either: the certificate is trusted because the user + * chose it, not because an authority vouched for it, so it stays valid until + * they replace it. + */ + case pinnedFingerprint(sha256: Data + ) + + + + + +} + +#if compiler(>=6) +extension TlsTrust: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTlsTrust: FfiConverterRustBuffer { + typealias SwiftType = TlsTrust + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TlsTrust { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .customCa(cert: try FfiConverterData.read(from: &buf) + ) + + case 2: return .pinnedFingerprint(sha256: try FfiConverterData.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: TlsTrust, into buf: inout [UInt8]) { + switch value { + + + case let .customCa(cert): + writeInt(&buf, Int32(1)) + FfiConverterData.write(cert, into: &buf) + + + case let .pinnedFingerprint(sha256): + writeInt(&buf, Int32(2)) + FfiConverterData.write(sha256, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTlsTrust_lift(_ buf: RustBuffer) throws -> TlsTrust { + return try FfiConverterTypeTlsTrust.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTlsTrust_lower(_ value: TlsTrust) -> RustBuffer { + return FfiConverterTypeTlsTrust.lower(value) +} + + + public enum Transaction { @@ -39100,6 +39441,30 @@ fileprivate struct FfiConverterOptionTypeTapSignerResponse: FfiConverterRustBuff } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeTlsTrust: FfiConverterRustBuffer { + typealias SwiftType = TlsTrust? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeTlsTrust.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeTlsTrust.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -41779,16 +42144,22 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_mnemonic_words() != 8009) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_nodeselector_certificate_decision() != 17478) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_nodeselector_check_and_save_node() != 42980) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_nodeselector_check_selected_node() != 34244) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_nodeselector_fetch_node_certificate() != 27543) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_nodeselector_node_list() != 26686) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_nodeselector_parse_custom_node() != 26788) { + if (uniffi_cove_checksum_method_nodeselector_parse_custom_node() != 15006) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_nodeselector_select_preset_node() != 55812) { From 419cc82c1e77dcd5b327105291d859bd2319691c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sun, 19 Jul 2026 17:07:46 -0400 Subject: [PATCH 5/9] Offer to trust a custom node's certificate Saving a custom Electrum node whose certificate cannot be verified now shows that certificate's SHA-256 fingerprint and asks whether to trust it, instead of failing with a connection error. The prompt appears only after normal verification has already failed, so nothing changes for a node with a certificate from a public CA, and there is no setting to turn on before it is needed. Accepting pins that exact certificate for that node alone. Certificate settings are carried into every later save and restored with the rest of the node, so trusting a certificate once does not turn into answering the same question on each save. Whether a rejected certificate can be offered at all is decided in the core, so both apps apply the same rule. The prompt is deliberately not offered for preset nodes, which are public servers where an unrecognized certificate is a reason to stop. --- .../flows/SettingsFlow/NodeSettingsScreen.kt | 79 ++++++++++++++- android/app/src/main/res/values/strings.xml | 7 ++ .../SettingsScreen/NodeSelectionView.swift | 96 +++++++++++++++++-- 3 files changed, 175 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt index 5d207cb0d..d40204445 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt @@ -41,9 +41,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -53,9 +55,14 @@ import org.bitcoinppl.cove.views.MaterialDivider import org.bitcoinppl.cove.views.MaterialSection import org.bitcoinppl.cove.views.SectionHeader import org.bitcoinppl.cove_core.ApiType +import org.bitcoinppl.cove_core.CertificateDecision +import org.bitcoinppl.cove_core.InternalException +import org.bitcoinppl.cove_core.NodeCertificate import org.bitcoinppl.cove_core.NodeSelection import org.bitcoinppl.cove_core.NodeSelector import org.bitcoinppl.cove_core.NodeSelectorException +import org.bitcoinppl.cove_core.TlsTrust + @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -78,6 +85,8 @@ fun NodeSettingsScreen( var isLoading by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } + var pendingCertificate by remember { mutableStateOf(null) } + var customTls by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf("") } var errorTitle by remember { mutableStateOf("") } @@ -93,6 +102,13 @@ fun NodeSettingsScreen( val errorUnknown = stringResource(R.string.node_error_unknown) val errorUrlEmpty = stringResource(R.string.node_error_url_empty) val errorParseTitle = stringResource(R.string.node_error_parse_title) + val certificateTitle = stringResource(R.string.node_certificate_title) + val certificateMessage = stringResource(R.string.node_certificate_message) + val certificateTrust = stringResource(R.string.node_certificate_trust) + val certificateCancel = stringResource(R.string.node_certificate_cancel) + val errorCertificateRead = stringResource(R.string.node_error_certificate_read) + val certificateChangedTitle = stringResource(R.string.node_certificate_changed_title) + val certificateChangedMessage = stringResource(R.string.node_certificate_changed_message) val showCustomFields = selectedNodeSelection is NodeSelection.Custom || @@ -123,6 +139,7 @@ fun NodeSettingsScreen( if (matchesType) { customUrl = node.url customNodeName = node.name + customTls = node.tls } } } @@ -174,6 +191,31 @@ fun NodeSettingsScreen( } } + fun showCertificateReadError(message: String?) { + errorTitle = errorConnectionFailed + errorMessage = String.format(Locale.US, errorCertificateRead, message.orEmpty()) + showErrorDialog = true + } + + // Whether the certificate can be offered for confirmation is decided in the + // core, so both apps apply the same rule. + suspend fun offerCertificate() { + try { + when (val decision = withContext(Dispatchers.IO) { nodeSelector.certificateDecision(customUrl) }) { + is CertificateDecision.Unrecognized -> pendingCertificate = decision.certificate + is CertificateDecision.Changed -> { + errorTitle = certificateChangedTitle + errorMessage = certificateChangedMessage + showErrorDialog = true + } + } + } catch (readError: NodeSelectorException) { + showCertificateReadError(readError.message) + } catch (readError: InternalException) { + showCertificateReadError(readError.message) + } + } + fun checkAndSaveCustomNode() { if (customUrl.isEmpty()) { errorTitle = errorTitleDefault @@ -187,7 +229,12 @@ fun NodeSettingsScreen( try { val node = withContext(Dispatchers.IO) { - nodeSelector.parseCustomNode(customUrl, selectedNodeName, customNodeName) + nodeSelector.parseCustomNode( + customUrl, + selectedNodeName, + customNodeName, + customTls, + ) } // update fields with parsed values @@ -211,6 +258,9 @@ fun NodeSettingsScreen( errorTitle = errorConnectionFailed errorMessage = errorConnectionMessage.format(e.v1) showErrorDialog = true + } catch (e: NodeSelectorException.CertificateNotTrusted) { + android.util.Log.d("NodeSettings", "certificate not trusted, offering it", e) + offerCertificate() } catch (e: Exception) { errorTitle = errorTitleDefault errorMessage = errorUnknown.format(e.message ?: "") @@ -221,6 +271,33 @@ fun NodeSettingsScreen( } } + pendingCertificate?.let { certificate -> + AlertDialog( + onDismissRequest = { pendingCertificate = null }, + title = { Text(certificateTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(MaterialSpacing.small)) { + Text(certificateMessage) + Text( + text = certificate.display, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + }, + confirmButton = { + TextButton(onClick = { + pendingCertificate = null + customTls = TlsTrust.PinnedFingerprint(certificate.sha256) + checkAndSaveCustomNode() + }) { Text(certificateTrust) } + }, + dismissButton = { + TextButton(onClick = { pendingCertificate = null }) { Text(certificateCancel) } + }, + ) + } + Scaffold( modifier = modifier diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 862421686..928ea7152 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -217,6 +217,13 @@ Unknown error: %1$s URL cannot be empty Unable to parse URL + Unrecognized certificate + This server uses a certificate Cove cannot verify. Only continue if this fingerprint matches the one your server reports. + Trust this certificate + Certificate changed + This server is presenting a different certificate to the one you trusted. It may have been reissued, or something may be intercepting the connection. Cove will not connect until it presents the certificate you trusted. + Cancel + Could not read the server\'s certificate: %1$s Back Menu QR Code diff --git a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift index 2324e0201..8230a4b32 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift @@ -24,9 +24,38 @@ struct NodeSelectionView: View { @State private var checkUrlTask: Task? + @State private var customTls: TlsTrust? + @State private var certificateAlert: CertificateDecision? + @State private var showCertificateAlert = false + init() { - selectedNodeName = nodeSelector.selectedNode().name + let selectedNode = nodeSelector.selectedNode() + + selectedNodeName = selectedNode.name nodeList = nodeSelector.nodeList() + + // Carry the saved node's certificate settings, so saving it again does + // not fall back to default trust and ask about the certificate afresh. + // These have defaults, so they must be set through their storage rather + // than assigned, or SwiftUI discards the value when it installs them. + if case let .custom(node) = selectedNode { + _customUrl = State(initialValue: node.url) + _customNodeName = State(initialValue: node.name) + _customTls = State(initialValue: node.tls) + } + } + + /// Whether the custom fields differ from the node that is already saved. + var hasUnsavedCustomNode: Bool { + guard case let .custom(saved) = nodeSelector.selectedNode() else { return !customUrl.isEmpty } + return saved.url != customUrl || saved.name != customNodeName + } + + var certificateAlertTitle: String { + switch certificateAlert { + case .changed: "Certificate changed" + default: "Unrecognized certificate" + } } var showCustomUrlField: Bool { @@ -109,7 +138,12 @@ struct NodeSelectionView: View { var node: Node? = nil do { - node = try nodeSelector.parseCustomNode(url: customUrl, name: selectedNodeName, enteredName: customNodeName) + node = try nodeSelector.parseCustomNode( + url: customUrl, + name: selectedNodeName, + enteredName: customNodeName, + tls: customTls + ) customUrl = node?.url ?? customUrl customNodeName = node?.name ?? customNodeName } catch { @@ -132,15 +166,40 @@ struct NodeSelectionView: View { refreshNodeState() completeLoading(.success("Connected to node successfully")) case let .failure(error): - let errorMessage = "Failed to connect to node\n \(error.localizedDescription)" - let formattedMessage = errorMessage.replacingOccurrences(of: "\\n", with: "\n") - - completeLoading(.failure(formattedMessage)) + // The server is reachable but its certificate was rejected. + if case NodeSelectorError.CertificateNotTrusted = error { + await offerCertificate() + } else { + let errorMessage = "Failed to connect to node\n \(error.localizedDescription)" + let formattedMessage = errorMessage.replacingOccurrences(of: "\\n", with: "\n") + + completeLoading(.failure(formattedMessage)) + } } } } } + /// Whether the certificate can be offered for confirmation is decided in the + /// core, so both apps apply the same rule. + func offerCertificate() async { + checkUrlTask = nil + + do { + let decision = try await nodeSelector.certificateDecision(url: customUrl) + + await dismissAllPopups() + // The popup dismissal is animated, so let it finish before + // presenting the alert, as the other flows here do. + try? await Task.sleep(for: .seconds(1)) + + certificateAlert = decision + showCertificateAlert = true + } catch { + completeLoading(.failure("Could not read the server's certificate\n \(error.localizedDescription)")) + } + } + var body: some View { Form { Section { @@ -206,11 +265,13 @@ struct NodeSelectionView: View { if savedSelectedNode.apiType == .electrum, selectedNodeName.contains("Electrum") { customUrl = savedSelectedNode.url customNodeName = savedSelectedNode.name + customTls = savedSelectedNode.tls } if savedSelectedNode.apiType == .esplora, selectedNodeName.contains("Esplora") { customUrl = savedSelectedNode.url customNodeName = savedSelectedNode.name + customTls = savedSelectedNode.tls } } @@ -246,6 +307,29 @@ struct NodeSelectionView: View { } ) } + .alert(certificateAlertTitle, isPresented: $showCertificateAlert, presenting: certificateAlert) { alert in + switch alert { + case let .unrecognized(certificate): + Button("Trust this certificate") { + certificateAlert = nil + customTls = .pinnedFingerprint(sha256: certificate.sha256) + checkAndSaveNode() + } + Button("Cancel", role: .cancel) { + certificateAlert = nil + Task { await dismissAllPopups() } + } + case .changed: + Button("OK", role: .cancel) { certificateAlert = nil } + } + } message: { alert in + switch alert { + case let .unrecognized(certificate): + Text("This server uses a certificate Cove cannot verify. Only continue if this fingerprint matches the one your server reports.\n\n\(certificate.display)") + case .changed: + Text("This server is presenting a different certificate to the one you trusted. It may have been reissued, or something may be intercepting the connection. Cove will not connect until it presents the certificate you trusted.") + } + } } } From 3607cf38360e8f84e0e1b93e12418c3f8b3dffa4 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Mon, 20 Jul 2026 14:54:35 -0500 Subject: [PATCH 6/9] Refactor node selection flow Extract certificate alert actions/message builders and simplify node parse/save error handling in the node selection view. Standardize Rust node-connect error conversions through ResultExt helpers. --- .../SettingsScreen/NodeSelectionView.swift | 110 +++++++++--------- rust/src/node_connect.rs | 16 ++- 2 files changed, 65 insertions(+), 61 deletions(-) diff --git a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift index 8230a4b32..494829e67 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift @@ -58,6 +58,34 @@ struct NodeSelectionView: View { } } + @ViewBuilder + private func certificateAlertActions(_ alert: CertificateDecision) -> some View { + switch alert { + case let .unrecognized(certificate): + Button("Trust this certificate") { + certificateAlert = nil + customTls = .pinnedFingerprint(sha256: certificate.sha256) + checkAndSaveNode() + } + Button("Cancel", role: .cancel) { + certificateAlert = nil + Task { await dismissAllPopups() } + } + case .changed: + Button("OK", role: .cancel) { certificateAlert = nil } + } + } + + @ViewBuilder + private func certificateAlertMessage(_ alert: CertificateDecision) -> some View { + switch alert { + case let .unrecognized(certificate): + Text("This server uses a certificate Cove cannot verify. Only continue if this fingerprint matches the one your server reports.\n\n\(certificate.display)") + case .changed: + Text("This server is presenting a different certificate to the one you trusted. It may have been reissued, or something may be intercepting the connection. Cove will not connect until it presents the certificate you trusted.") + } + } + var showCustomUrlField: Bool { selectedNodeName.hasPrefix("Custom") } @@ -135,8 +163,7 @@ struct NodeSelectionView: View { } func checkAndSaveNode() { - var node: Node? = nil - + let node: Node do { node = try nodeSelector.parseCustomNode( url: customUrl, @@ -144,38 +171,33 @@ struct NodeSelectionView: View { enteredName: customNodeName, tls: customTls ) - customUrl = node?.url ?? customUrl - customNodeName = node?.name ?? customNodeName + customUrl = node.url + customNodeName = node.name + } catch let NodeSelectorError.ParseNodeUrlError(errorString) { + showParseUrlAlert = true + parseUrlMessage = errorString + return } catch { showParseUrlAlert = true - switch error { - case let NodeSelectorError.ParseNodeUrlError(errorString): - parseUrlMessage = errorString - default: - parseUrlMessage = "Unknown error \(error.localizedDescription)" - } + parseUrlMessage = "Unknown error \(error.localizedDescription)" + return } - if let node { - Task { - showLoadingPopup() - let result = await Result { try await nodeSelector.checkAndSaveNode(node: node) } + Task { + showLoadingPopup() - switch result { - case .success: - refreshNodeState() - completeLoading(.success("Connected to node successfully")) - case let .failure(error): - // The server is reachable but its certificate was rejected. - if case NodeSelectorError.CertificateNotTrusted = error { - await offerCertificate() - } else { - let errorMessage = "Failed to connect to node\n \(error.localizedDescription)" - let formattedMessage = errorMessage.replacingOccurrences(of: "\\n", with: "\n") - - completeLoading(.failure(formattedMessage)) - } - } + do { + try await nodeSelector.checkAndSaveNode(node: node) + refreshNodeState() + completeLoading(.success("Connected to node successfully")) + } catch NodeSelectorError.CertificateNotTrusted { + // The server is reachable but its certificate was rejected. + await offerCertificate() + } catch { + let errorMessage = "Failed to connect to node\n \(error.localizedDescription)" + let formattedMessage = errorMessage.replacingOccurrences(of: "\\n", with: "\n") + + completeLoading(.failure(formattedMessage)) } } } @@ -307,29 +329,13 @@ struct NodeSelectionView: View { } ) } - .alert(certificateAlertTitle, isPresented: $showCertificateAlert, presenting: certificateAlert) { alert in - switch alert { - case let .unrecognized(certificate): - Button("Trust this certificate") { - certificateAlert = nil - customTls = .pinnedFingerprint(sha256: certificate.sha256) - checkAndSaveNode() - } - Button("Cancel", role: .cancel) { - certificateAlert = nil - Task { await dismissAllPopups() } - } - case .changed: - Button("OK", role: .cancel) { certificateAlert = nil } - } - } message: { alert in - switch alert { - case let .unrecognized(certificate): - Text("This server uses a certificate Cove cannot verify. Only continue if this fingerprint matches the one your server reports.\n\n\(certificate.display)") - case .changed: - Text("This server is presenting a different certificate to the one you trusted. It may have been reissued, or something may be intercepting the connection. Cove will not connect until it presents the certificate you trusted.") - } - } + .alert( + certificateAlertTitle, + isPresented: $showCertificateAlert, + presenting: certificateAlert, + actions: certificateAlertActions, + message: certificateAlertMessage + ) } } diff --git a/rust/src/node_connect.rs b/rust/src/node_connect.rs index a8a82c852..567de9f16 100644 --- a/rust/src/node_connect.rs +++ b/rust/src/node_connect.rs @@ -5,6 +5,7 @@ use crate::node::client::electrum::transport; use crate::node::tls::{self, TlsTrust}; use crate::{database::Database, network::Network, node::Node}; use cove_macros::impl_default_for; +use cove_util::ResultExt as _; use eyre::{Context, eyre}; pub const BITCOIN_ESPLORA: [(&str, &str); 1] = @@ -153,14 +154,14 @@ impl NodeSelector { Database::global() .global_config .set_selected_node(&node) - .map_err(|error| NodeSelectorError::SetSelectedNodeError(error.to_string()))?; + .map_err_str(NodeSelectorError::SetSelectedNodeError)?; Ok(node) } #[uniffi::method] pub async fn check_selected_node(&self, node: Node) -> Result<(), Error> { - node.check_url().await.map_err(|error| Error::NodeAccessError(format!("{error:?}")))?; + node.check_url().await.map_err_debug(Error::NodeAccessError)?; Ok(()) } @@ -176,8 +177,7 @@ impl NodeSelector { ) -> Result { let node_type = name.to_ascii_lowercase(); - let url = - parse_node_url(&url).map_err(|error| Error::ParseNodeUrlError(error.to_string()))?; + let url = parse_node_url(&url).map_err_str(Error::ParseNodeUrlError)?; if !has_usable_host(&url) { return Err(Error::ParseNodeUrlError("invalid url, no domain".to_string())); @@ -246,7 +246,7 @@ impl NodeSelector { let certificate = cove_tokio::unblock::run_blocking(move || transport::peer_certificate(&url)) .await - .map_err(|error| Error::ReadCertificateError(error.to_string()))?; + .map_err_str(Error::ReadCertificateError)?; let sha256 = tls::fingerprint(&certificate); @@ -278,7 +278,7 @@ impl NodeSelector { Database::global() .global_config .set_selected_node(&node) - .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?; + .map_err_str(Error::SetSelectedNodeError)?; Ok(()) } @@ -356,9 +356,7 @@ fn would_forget_certificate(saved: &Node, node: &Node) -> bool { } fn normalized_url(url: &str) -> Result { - let url = parse_node_url(url) - .map_err(|error| Error::ParseNodeUrlError(error.to_string()))? - .to_string(); + let url = parse_node_url(url).map_err_str(Error::ParseNodeUrlError)?.to_string(); Ok(url.strip_suffix('/').unwrap_or(&url).to_string()) } From 1000a9b79d9daf47eb253201dce9d6eaec949e05 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Mon, 20 Jul 2026 14:54:36 -0500 Subject: [PATCH 7/9] Bump Android versionCode to 37 Increase the application versionCode to reflect the latest Android release increment. --- android/app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ab373a50f..05e02b7d6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,7 +15,7 @@ android { applicationId = "org.bitcoinppl.cove" minSdk = 33 targetSdk = 36 - versionCode = 28 + versionCode = 37 versionName = "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From dbcc681eff518f99c6e1f585e1f19c116581f845 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Wed, 22 Jul 2026 18:27:52 -0400 Subject: [PATCH 8/9] Negotiate the Electrum protocol version on pinned connections RawClient::from only wraps the stream, so server.version was never sent and block_headers was decoded as 1.4. Negotiate on connect and reconnect. --- rust/src/node/client/electrum/test_server.rs | 17 +- rust/src/node/client/electrum/transport.rs | 171 +++++++++++++++++-- 2 files changed, 167 insertions(+), 21 deletions(-) diff --git a/rust/src/node/client/electrum/test_server.rs b/rust/src/node/client/electrum/test_server.rs index 8f51c5e42..9cb32440a 100644 --- a/rust/src/node/client/electrum/test_server.rs +++ b/rust/src/node/client/electrum/test_server.rs @@ -13,6 +13,10 @@ use crate::node::tls::{self, TlsTrust}; pub const TEST_HEIGHT: usize = 840_000; +/// What the server negotiates, so the newer `blockchain.block.headers` shape is +/// what the client has to decode. +pub const PROTOCOL_VERSION: &str = "1.6"; + const GENESIS_HEADER: &str = "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c"; /// The client installs its own crypto provider, but the test server and cove's @@ -179,7 +183,14 @@ fn response(request: &str) -> String { .and_then(|digits| digits.parse::().ok()) .unwrap_or(0); - format!( - "{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{{\"height\":{TEST_HEIGHT},\"hex\":\"{GENESIS_HEADER}\"}}}}\n" - ) + let result = if request.contains("server.version") { + format!("[\"cove test server\",\"{PROTOCOL_VERSION}\"]") + } else if request.contains("blockchain.block.headers") { + // The 1.6 shape, which is what a server answering with that version sends. + format!("{{\"count\":1,\"max\":2016,\"headers\":[\"{GENESIS_HEADER}\"]}}") + } else { + format!("{{\"height\":{TEST_HEIGHT},\"hex\":\"{GENESIS_HEADER}\"}}") + }; + + format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}\n") } diff --git a/rust/src/node/client/electrum/transport.rs b/rust/src/node/client/electrum/transport.rs index aa2f04a8a..cab48c87a 100644 --- a/rust/src/node/client/electrum/transport.rs +++ b/rust/src/node/client/electrum/transport.rs @@ -3,12 +3,16 @@ use std::net::TcpStream; use std::sync::{Arc, RwLock}; use std::time::Duration; -use bdk_electrum::electrum_client::raw_client::{ElectrumSslStream, RawClient}; +use bdk_electrum::electrum_client::raw_client::{ + CLIENT_NAME, ElectrumSslStream, PROTOCOL_VERSION_MAX, PROTOCOL_VERSION_MIN, RawClient, +}; use bdk_electrum::electrum_client::{ Batch, BroadcastPackageRes, Client, ElectrumApi, Error, EstimationMode, GetBalanceRes, GetHeadersRes, GetHistoryRes, GetMerkleRes, ListUnspentRes, MempoolInfoRes, Param, - RawHeaderNotification, ScriptStatus, ServerFeaturesRes, TxidFromPosRes, + RawHeaderNotification, ScriptStatus, ServerFeaturesRes, ServerVersionRes, TxidFromPosRes, }; +use bitcoin::consensus::deserialize; +use bitcoin::hex::FromHex as _; use bitcoin::{Script, Txid}; use rustls::pki_types::{CertificateDer, ServerName}; use rustls::{CertificateError, ClientConnection, StreamOwned}; @@ -56,19 +60,29 @@ pub struct Pinned { struct Connection { client: RawClient, + /// What `server.version` settled on for this socket. Renegotiated on every + /// reconnect, since the server may not be the one answering next time. + protocol_version: String, /// Bumped on every reconnect, so callers that failed on an older connection /// can tell it has already been replaced. generation: u64, } +/// 1.6 changed the shape of `blockchain.block.headers`. +fn is_at_least_1_6(version: &str) -> bool { + let mut parts = version.split('.'); + let major = parts.next().and_then(|part| part.parse::().ok()).unwrap_or(0); + let minor = parts.next().and_then(|part| part.parse::().ok()).unwrap_or(0); + + (major, minor) >= (1, 6) +} + impl Pinned { fn connect(url: &str, trust: &TlsTrust) -> Result { - let client = connect(url, trust)?; - Ok(Self { url: url.to_string(), trust: trust.clone(), - connection: RwLock::new(Connection { client, generation: 0 }), + connection: RwLock::new(connect(url, trust)?), }) } @@ -76,15 +90,12 @@ impl Pinned { /// /// Mirrors electrum-client's retry policy: a protocol error came from the /// server and will repeat, anything else may be a broken socket. - fn call( - &self, - call: impl Fn(&RawClient) -> Result, - ) -> Result { + fn call(&self, call: impl Fn(&Connection) -> Result) -> Result { // Scoped so the read guard is released before the write below. let (error, generation) = { let connection = self.read(); - match call(&connection.client) { + match call(&connection) { Ok(value) => return Ok(value), Err(error @ (Error::Protocol(_) | Error::AlreadySubscribed(_))) => { return Err(error); @@ -100,13 +111,14 @@ impl Pinned { // Another caller may already have replaced the dead connection. if connection.generation == generation { - connection.client = connect(&self.url, &self.trust) + let replacement = connect(&self.url, &self.trust) .map_err(|error| Error::Message(error.to_string()))?; - connection.generation += 1; + + *connection = Connection { generation: generation + 1, ..replacement }; } } - call(&self.read().client) + call(&self.read()) } /// A panic in one call must not wedge the node for the rest of the session, @@ -145,9 +157,12 @@ pub enum ConnectError { #[error("failed to connect: {0}")] Connect(std::io::Error), + + #[error("failed to negotiate a protocol version: {0}")] + Negotiate(Error), } -fn connect(url: &str, trust: &TlsTrust) -> Result, ConnectError> { +fn connect(url: &str, trust: &TlsTrust) -> Result { let (host, port) = target(url)?; // A pinned fingerprint ignores this name, but rustls still requires a @@ -156,7 +171,71 @@ fn connect(url: &str, trust: &TlsTrust) -> Result, .map_err(|_| ConnectError::InvalidHost(host.clone()))? .to_owned(); - handshake(server_name, tls::client_config(trust)?, &host, port).map(RawClient::from) + let stream = handshake(server_name, tls::client_config(trust)?, &host, port)?; + let client = RawClient::from(stream); + let protocol_version = negotiate(&client).map_err(ConnectError::Negotiate)?; + + Ok(Connection { client, protocol_version, generation: 0 }) +} + +/// Send `server.version`, which protocol 1.6 requires before anything else. +/// +/// [`RawClient::from`] only wraps a stream, and the negotiation +/// electrum-client does in its own constructors is private, so it is repeated +/// here. The version it settles on is kept because electrum-client cannot tell +/// us the one it recorded, and `blockchain.block.headers` is decoded from it. +fn negotiate(client: &RawClient) -> Result { + let versions = vec![PROTOCOL_VERSION_MIN.to_string(), PROTOCOL_VERSION_MAX.to_string()]; + + let response = client.raw_call( + "server.version", + vec![Param::String(CLIENT_NAME.to_string()), Param::StringVec(versions)], + )?; + + let response: ServerVersionRes = serde_json::from_value(response)?; + + Ok(response.protocol_version) +} + +/// Decode `blockchain.block.headers` for this connection's protocol version. +/// +/// electrum-client only decodes the 1.6 shape when it negotiated the version +/// itself, which it cannot do for a caller supplied session, so it would read a +/// 1.6 response as a 1.4 one and fail the wallet scan. +fn block_headers( + connection: &Connection, + start_height: usize, + count: usize, +) -> Result { + // Before 1.6 electrum-client's own decoding is already correct. + if !is_at_least_1_6(&connection.protocol_version) { + return connection.client.block_headers(start_height, count); + } + + let response = connection.client.raw_call( + "blockchain.block.headers", + vec![Param::Usize(start_height), Param::Usize(count)], + )?; + + // `GetHeadersRes` keeps the hex strings in a private field, so they are + // read back out of the response rather than from the decoded value. + let hexes = response + .get("headers") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| Error::InvalidResponse(response.clone()))?; + + let headers = hexes + .iter() + .map(|hex| { + let hex = hex.as_str().ok_or_else(|| Error::InvalidResponse(response.clone()))?; + Ok(deserialize(&Vec::::from_hex(hex)?)?) + }) + .collect::, Error>>()?; + + let mut decoded: GetHeadersRes = serde_json::from_value(response.clone())?; + decoded.headers = headers; + + Ok(decoded) } /// Read the certificate a server presents, so the user can confirm it before @@ -251,7 +330,9 @@ macro_rules! dispatch { ($self:expr, $method:ident $(, $arg:expr)*) => { match $self { Transport::Default(inner) => inner.$method($($arg),*), - Transport::Pinned(pinned) => pinned.call(|inner| inner.$method($($arg),*)), + Transport::Pinned(pinned) => { + pinned.call(|connection| connection.client.$method($($arg),*)) + } } }; } @@ -284,7 +365,12 @@ impl ElectrumApi for Transport { } fn block_headers(&self, start_height: usize, count: usize) -> Result { - dispatch!(self, block_headers, start_height, count) + match self { + Transport::Default(inner) => inner.block_headers(start_height, count), + Transport::Pinned(pinned) => { + pinned.call(|connection| block_headers(connection, start_height, count)) + } + } } fn estimate_fee(&self, number: usize, mode: Option) -> Result { @@ -442,7 +528,7 @@ mod tests { use super::*; use crate::node::client::electrum::ElectrumClient; use crate::node::client::electrum::test_server::{ - Authority, TEST_HEIGHT, TestServer, self_signed, setup, + Authority, PROTOCOL_VERSION, TEST_HEIGHT, TestServer, self_signed, setup, }; use crate::node::{ApiType, Node}; use cove_types::network::Network; @@ -554,6 +640,55 @@ mod tests { assert!(error.contains("UnknownIssuer"), "{error}"); } + /// Protocol 1.6 requires `server.version` before anything else, and the + /// negotiated version decides how `blockchain.block.headers` is decoded. + #[tokio::test] + async fn a_pinned_connection_negotiates_the_protocol_version() { + setup(); + let server = TestServer::self_signed("localhost"); + let trust = server.fingerprint_trust(); + let url = format!("ssl://localhost:{}", server.port); + + let transport = Transport::connect_pinned(&url, &trust).unwrap(); + let Transport::Pinned(pinned) = &transport else { panic!("not pinned") }; + + assert_eq!(pinned.read().protocol_version, PROTOCOL_VERSION); + assert_eq!(transport.block_headers(TEST_HEIGHT, 1).unwrap().headers.len(), 1); + } + + /// A reconnect starts a new session, so it has to negotiate again rather + /// than carry the previous connection's version. + #[tokio::test] + async fn a_reconnect_negotiates_again() { + setup(); + let server = TestServer::self_signed("localhost"); + let trust = server.fingerprint_trust(); + let url = format!("ssl://localhost:{}", server.port); + + let transport = Transport::connect_pinned(&url, &trust).unwrap(); + let Transport::Pinned(pinned) = &transport else { panic!("not pinned") }; + + // The server answers this one and then drops the socket, so it is the + // call after it that has to rebuild the connection. + server.hang_up_once(); + assert_eq!(transport.block_headers(TEST_HEIGHT, 1).unwrap().headers.len(), 1); + assert_eq!(transport.block_headers(TEST_HEIGHT, 1).unwrap().headers.len(), 1); + + let connection = pinned.read(); + assert_eq!(connection.generation, 1, "did not reconnect"); + assert_eq!(connection.protocol_version, PROTOCOL_VERSION); + } + + #[test] + fn protocol_versions_compare_by_number() { + assert!(is_at_least_1_6("1.6")); + assert!(is_at_least_1_6("1.10")); + assert!(is_at_least_1_6("2.0")); + assert!(!is_at_least_1_6("1.4")); + assert!(!is_at_least_1_6("1.5")); + assert!(!is_at_least_1_6("")); + } + /// The client is cached for the lifetime of a wallet, so it has to survive /// the server dropping the connection. #[tokio::test] From 67924f6772d08af55b32b82d445cbc7adb581bf2 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Wed, 22 Jul 2026 18:27:52 -0400 Subject: [PATCH 9/9] Scope certificate trust to the url it was accepted for Editing the url or switching to Esplora reused the saved pin. parse_custom_node now inherits it, only for an unchanged Electrum url. --- .../flows/SettingsFlow/NodeSettingsScreen.kt | 13 ++- .../SettingsScreen/NodeSelectionView.swift | 18 ++-- rust/src/node_connect.rs | 82 ++++++++++++++++++- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt index d40204445..da63857fe 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt @@ -86,7 +86,11 @@ fun NodeSettingsScreen( var isLoading by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } var pendingCertificate by remember { mutableStateOf(null) } + // A certificate accepted in this session, with the url it was accepted for + // so editing the url does not check a different server against it. A saved + // node's settings are not held here: parseCustomNode carries those forward. var customTls by remember { mutableStateOf(null) } + var customTlsUrl by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf("") } var errorTitle by remember { mutableStateOf("") } @@ -139,7 +143,6 @@ fun NodeSettingsScreen( if (matchesType) { customUrl = node.url customNodeName = node.name - customTls = node.tls } } } @@ -233,7 +236,7 @@ fun NodeSettingsScreen( customUrl, selectedNodeName, customNodeName, - customTls, + if (customTlsUrl == customUrl) customTls else null, ) } @@ -241,6 +244,11 @@ fun NodeSettingsScreen( customUrl = node.url customNodeName = node.name + // The url has just been normalized, so follow it, otherwise a + // retry after a failed save would ask about the same + // certificate again. + if (node.tls != null) customTlsUrl = node.url + withContext(Dispatchers.IO) { nodeSelector.checkAndSaveNode(node) } @@ -289,6 +297,7 @@ fun NodeSettingsScreen( TextButton(onClick = { pendingCertificate = null customTls = TlsTrust.PinnedFingerprint(certificate.sha256) + customTlsUrl = customUrl checkAndSaveCustomNode() }) { Text(certificateTrust) } }, diff --git a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift index 494829e67..91d7e7bba 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift @@ -24,7 +24,13 @@ struct NodeSelectionView: View { @State private var checkUrlTask: Task? + /// A certificate accepted in this session, which belongs to the url it was + /// accepted for. A saved node's settings are not held here: `parseCustomNode` + /// carries those forward, so an edited url cannot inherit the old pin. @State private var customTls: TlsTrust? + /// The url `customTls` was accepted for, so editing the url does not check + /// a different server against it. + @State private var customTlsUrl: String? @State private var certificateAlert: CertificateDecision? @State private var showCertificateAlert = false @@ -34,14 +40,11 @@ struct NodeSelectionView: View { selectedNodeName = selectedNode.name nodeList = nodeSelector.nodeList() - // Carry the saved node's certificate settings, so saving it again does - // not fall back to default trust and ask about the certificate afresh. // These have defaults, so they must be set through their storage rather // than assigned, or SwiftUI discards the value when it installs them. if case let .custom(node) = selectedNode { _customUrl = State(initialValue: node.url) _customNodeName = State(initialValue: node.name) - _customTls = State(initialValue: node.tls) } } @@ -65,6 +68,7 @@ struct NodeSelectionView: View { Button("Trust this certificate") { certificateAlert = nil customTls = .pinnedFingerprint(sha256: certificate.sha256) + customTlsUrl = customUrl checkAndSaveNode() } Button("Cancel", role: .cancel) { @@ -169,10 +173,14 @@ struct NodeSelectionView: View { url: customUrl, name: selectedNodeName, enteredName: customNodeName, - tls: customTls + tls: customTlsUrl == customUrl ? customTls : nil ) customUrl = node.url customNodeName = node.name + + // The url has just been normalized, so follow it, otherwise a retry + // after a failed save would ask about the same certificate again. + if node.tls != nil { customTlsUrl = node.url } } catch let NodeSelectorError.ParseNodeUrlError(errorString) { showParseUrlAlert = true parseUrlMessage = errorString @@ -287,13 +295,11 @@ struct NodeSelectionView: View { if savedSelectedNode.apiType == .electrum, selectedNodeName.contains("Electrum") { customUrl = savedSelectedNode.url customNodeName = savedSelectedNode.name - customTls = savedSelectedNode.tls } if savedSelectedNode.apiType == .esplora, selectedNodeName.contains("Esplora") { customUrl = savedSelectedNode.url customNodeName = savedSelectedNode.name - customTls = savedSelectedNode.tls } } diff --git a/rust/src/node_connect.rs b/rust/src/node_connect.rs index 567de9f16..ab6473f97 100644 --- a/rust/src/node_connect.rs +++ b/rust/src/node_connect.rs @@ -40,6 +40,9 @@ pub const SIGNET_ESPLORA: [(&str, &str); 1] = [("mutinynet", "https://mutinynet. pub struct NodeSelector { network: Network, node_list: Vec, + /// The node that was selected when this selector was built, so a custom + /// node can inherit the certificate settings already saved for its url. + saved_node: Node, } #[derive(Debug, Clone, uniffi::Enum, PartialEq, Eq, Hash)] @@ -118,7 +121,7 @@ impl NodeSelector { node_selection_list }; - Self { network, node_list: node_selection_list } + Self { network, node_list: node_selection_list, saved_node: selected_node } } #[uniffi::method] @@ -200,6 +203,13 @@ impl NodeSelector { )); } + // Certificate settings belong to the url they were accepted for, so + // an unchanged url keeps what is saved and an edited one starts + // clean rather than checking a new server against the old + // certificate. A `tls` passed in is one the user has just accepted, + // so it wins. + let tls = tls.or_else(|| trusted_certificate(&self.saved_node, &url_string)); + Node { tls, ..Node::new_electrum(name, url_string, self.network) } } else if node_type.contains("esplora") { // Silently dropping the setting here would contradict the Esplora @@ -449,7 +459,75 @@ mod tests { use super::*; fn selector() -> NodeSelector { - NodeSelector { network: Network::Bitcoin, node_list: Vec::new() } + selector_with_saved(Node::default(Network::Bitcoin)) + } + + fn selector_with_saved(saved_node: Node) -> NodeSelector { + NodeSelector { network: Network::Bitcoin, node_list: Vec::new(), saved_node } + } + + fn saved_pinned_node(url: &str, trust: &TlsTrust) -> Node { + Node { + tls: Some(trust.clone()), + ..Node::new_electrum("saved".to_string(), url.to_string(), Network::Bitcoin) + } + } + + /// Re-saving the node that is already selected must not fall back to + /// default trust and ask about its certificate all over again. + #[test] + fn an_unchanged_url_inherits_the_certificate_it_already_trusts() { + let trust = TlsTrust::PinnedFingerprint { sha256: vec![7; 32] }; + let saved = saved_pinned_node("ssl://node.example.com:50002", &trust); + + let node = selector_with_saved(saved) + .parse_custom_node( + "ssl://node.example.com:50002".to_string(), + "Custom Electrum".to_string(), + String::new(), + None, + ) + .unwrap(); + + assert_eq!(node.tls, Some(trust)); + } + + /// Editing the url points at a different server, which the previous + /// certificate says nothing about. + #[test] + fn an_edited_url_does_not_inherit_the_previous_certificate() { + let trust = TlsTrust::PinnedFingerprint { sha256: vec![7; 32] }; + let saved = saved_pinned_node("ssl://node.example.com:50002", &trust); + + let node = selector_with_saved(saved) + .parse_custom_node( + "ssl://other.example.com:50002".to_string(), + "Custom Electrum".to_string(), + String::new(), + None, + ) + .unwrap(); + + assert_eq!(node.tls, None); + } + + /// Esplora refuses certificate settings, so inheriting them would make the + /// node impossible to save. + #[test] + fn switching_to_esplora_does_not_inherit_the_certificate() { + let trust = TlsTrust::PinnedFingerprint { sha256: vec![7; 32] }; + let saved = saved_pinned_node("ssl://node.example.com:50002", &trust); + + let node = selector_with_saved(saved) + .parse_custom_node( + "https://node.example.com".to_string(), + "Custom Esplora".to_string(), + String::new(), + None, + ) + .unwrap(); + + assert_eq!(node.tls, None); } fn parse(url: &str) -> Result {