Skip to content

Support self-signed Electrum certificates - #843

Open
kwsantiago wants to merge 11 commits into
bitcoinppl:masterfrom
privkeyio:electrum-self-signed-certs
Open

kwsantiago wants to merge 11 commits into
bitcoinppl:masterfrom
privkeyio:electrum-self-signed-certs

Conversation

@kwsantiago

@kwsantiago kwsantiago commented Jul 19, 2026

Copy link
Copy Markdown

Closes #298.

A custom Electrum node could only be reached over a chain anchored in the bundled webpki roots, so a self hosted server was unreachable unless its certificate came from a public CA.

Node gains an optional TlsTrust. Unset keeps today's behavior exactly. Saving a custom node whose certificate cannot be verified now shows that certificate's SHA-256 fingerprint and asks whether to trust it.

Two rules make it opt in without adding a setting:

  • The prompt appears only after normal verification has already failed, so it is never put to a user who does not have to answer it, and never for preset nodes.
  • A url that already trusts a certificate is never offered a different one, it is told the certificate changed. Otherwise trust on first use becomes trust on every save. That decision lives in certificate_decision rather than in each app, so both platforms apply the same rule and it can be tested.

Pinning still verifies the handshake signature, so the peer must hold the pinned key. It authenticates against a certificate rather than a name, which is not the same as skipping verification.

Notes

  • electrum-client only takes a caller supplied rustls session through RawClient, which has no reconnect loop, so Transport keeps what it needs to rebuild the connection. The client is cached for a wallet's lifetime, so without that one dropped socket would break a pinned node until restart.
  • Esplora and non-ssl:// urls refuse certificate settings rather than connecting with weaker trust than was asked for.
  • Hosts may be IP addresses; an IPv6 literal was rejected outright before.
  • TlsTrust::CustomCa validates against a user supplied CA and still checks the hostname, so a privately issued leaf can rotate without re-pinning. It is tested, but only the fingerprint mode has UI here since that is what the issue asks for and it needs no file picker. Happy to wire up the paste-a-certificate field as a follow-up, or drop the variant if you would rather it landed with its own UI.
  • No new runtime dependencies; rcgen is dev only.
  • Not Local certificate store is not used #776, which wants the device's CA store and needs rustls-platform-verifier or equivalent.

Testing

1617 Rust tests, fmt and clippy -D warnings clean. Tests cover the pinned and CA paths, hostname mismatch by IP, IPv6, leaf rotation, that default trust still rejects self-signed, that a rejected certificate is distinguished from an unreachable node, and reconnect including concurrent callers.

Verified on a Pixel 9a against a self-signed front end to a real Electrum server:

Step Server App
default trust REJECTED: UnknownCA rejected
reading the certificate OK, 0 requests no traffic carried
fingerprint shown matches the server's
accepting OK, 1 request connects
a different host OK, 0 requests offers that host's fingerprint
wallet sync OK, 62 requests scan completes
saving again OK, 1 request no prompt
restart, reissued certificate REJECTED: AccessDenied pin enforced

The last row used no interaction: the app was relaunched and loaded the pin itself.

The Android flow above was exercised on device. The iOS side compiles but has not been run on a device, so the flow itself is unverified there.

Bindings are regenerated here; say the word if you would rather they came from the Regenerate Bindings workflow.

Known limitations

  • Cove stores one selected node, so selecting a preset and returning loses the pin and asks again. Keeping trust per url is a larger change than this PR.
  • No way to forget a trusted certificate, so a server that legitimately reissues one has to be re-added. Obvious follow-up.
  • The other asks in Allow connecting to nodes using self signed SSL certs #298, an SSL toggle, a connection indicator and an offline mode, are out of scope.

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.
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.
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.
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.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fde8472c-c5fd-45be-81ce-0426412ac3f4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses #298 by enabling self-signed Electrum SSL connections with certificate trust/pinning; the extra SSL-toggle and offline-mode ideas are explicitly out of scope.
Out of Scope Changes check ✅ Passed No clearly unrelated code changes are evident; the added custom CA support, bindings, and platform updates all support the certificate-trust feature.
Docstring Coverage ✅ Passed Docstring coverage is 86.82% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly summarizes the main change: adding support for self-signed Electrum certificates.
Description check ✅ Passed The description is mostly complete and includes summary, notes, testing, and limitations, though it omits the template's Platform Coverage and Checklist sections.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds opt-in certificate trust for custom Electrum nodes. The main changes are:

  • Persist TLS trust settings with custom nodes.
  • Show certificate fingerprints after normal TLS validation fails.
  • Add pinned-certificate and custom-CA Electrum transports.
  • Reconnect pinned Electrum connections after socket failures.
  • Reject TLS trust settings for unsupported Esplora nodes.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
rust/src/node/tls.rs Adds custom CA and fingerprint-pinning TLS configuration and certificate capture helpers.
rust/src/node/client/electrum/transport.rs Adds a TLS-aware Electrum transport with synchronized reconnect handling for pinned connections.
rust/src/node_connect.rs Adds certificate decisions, URL normalization, and persistence safeguards for custom-node trust.
rust/src/node/client/esplora.rs Rejects custom TLS trust settings for Esplora connections.
android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt Adds the Android certificate fingerprint prompt and retry flow with pinned trust.
ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift Adds the iOS certificate decision prompt and preserves TLS trust for saved custom nodes.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App as Mobile app
    participant Selector as Node selector
    participant Transport as Electrum transport
    participant Server as Electrum server

    App->>Selector: Parse custom node with optional trust
    App->>Selector: Check and save node
    Selector->>Transport: Connect with default TLS
    Transport->>Server: TLS handshake
    alt Default validation succeeds
        Transport-->>Selector: Connected
        Selector-->>App: Save node
    else Certificate rejected
        Transport-->>Selector: Certificate error
        Selector-->>App: Certificate decision
        App->>Selector: Retry with pinned fingerprint
        Selector->>Transport: Connect with pinned TLS
        Transport->>Server: TLS handshake and request
        Transport-->>Selector: Connected
        Selector-->>App: Save pinned node
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant App as Mobile app
    participant Selector as Node selector
    participant Transport as Electrum transport
    participant Server as Electrum server

    App->>Selector: Parse custom node with optional trust
    App->>Selector: Check and save node
    Selector->>Transport: Connect with default TLS
    Transport->>Server: TLS handshake
    alt Default validation succeeds
        Transport-->>Selector: Connected
        Selector-->>App: Save node
    else Certificate rejected
        Transport-->>Selector: Certificate error
        Selector-->>App: Certificate decision
        App->>Selector: Retry with pinned fingerprint
        Selector->>Transport: Connect with pinned TLS
        Transport->>Server: TLS handshake and request
        Transport-->>Selector: Connected
        Selector-->>App: Save pinned node
    end
Loading

Reviews (1): Last reviewed commit: "Offer to trust a custom node's certifica..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
rust/src/node_connect.rs (1)

249-249: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use ResultExt::map_err_str for these error conversions. These new sites use the .map_err(|e| Error::Variant(e.to_string())) pattern the project asks to avoid.

♻️ Suggested change
-        let certificate =
-            cove_tokio::unblock::run_blocking(move || transport::peer_certificate(&url))
-                .await
-                .map_err(|error| Error::ReadCertificateError(error.to_string()))?;
+        let certificate =
+            cove_tokio::unblock::run_blocking(move || transport::peer_certificate(&url))
+                .await
+                .map_err_str(Error::ReadCertificateError)?;
-fn normalized_url(url: &str) -> Result<String, Error> {
-    let url = parse_node_url(url)
-        .map_err(|error| Error::ParseNodeUrlError(error.to_string()))?
-        .to_string();
+fn normalized_url(url: &str) -> Result<String, Error> {
+    let url = parse_node_url(url).map_err_str(Error::ParseNodeUrlError)?.to_string();

As per coding guidelines: "Use cove_util::ResultExt::map_err_str and ..map_err_prefix instead of .map_err(|e| Error::Variant(e.to_string()))".

Also applies to: 358-361

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/src/node_connect.rs` at line 249, Replace the inline error-to-string
conversions in the certificate-reading flow, including the site around
Error::ReadCertificateError and the additional sites around lines 358–361, with
cove_util::ResultExt::map_err_str. Preserve each existing Error variant and
conversion behavior while applying the project-standard extension method.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rust/src/node_connect.rs`:
- Line 249: Replace the inline error-to-string conversions in the
certificate-reading flow, including the site around Error::ReadCertificateError
and the additional sites around lines 358–361, with
cove_util::ResultExt::map_err_str. Preserve each existing Error variant and
conversion behavior while applying the project-standard extension method.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a949b9b1-7537-4574-9ab9-edd704f514f8

📥 Commits

Reviewing files that changed from the base of the PR and between b82a236 and 419cc82.

⛔ Files ignored due to path filters (3)
  • android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt is excluded by !android/app/src/main/java/org/bitcoinppl/cove_core/**
  • ios/CoveCore/Sources/CoveCore/generated/cove.swift is excluded by !**/generated/**, !ios/CoveCore/Sources/CoveCore/generated/**
  • rust/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt
  • android/app/src/main/res/values/strings.xml
  • ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift
  • rust/Cargo.toml
  • rust/src/node.rs
  • rust/src/node/client.rs
  • rust/src/node/client/electrum.rs
  • rust/src/node/client/electrum/test_server.rs
  • rust/src/node/client/electrum/transport.rs
  • rust/src/node/client/esplora.rs
  • rust/src/node/tls.rs
  • rust/src/node_connect.rs

Comment thread rust/src/node/client/electrum/transport.rs Outdated
Comment thread ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift Outdated
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.
Increase the application versionCode to reflect the latest Android release increment.
@praveenperera

Copy link
Copy Markdown
Member

also bring these over 3607cf3

@kwsantiago
kwsantiago force-pushed the electrum-self-signed-certs branch from 432f3b8 to 1692ad1 Compare July 22, 2026 22:15
@kwsantiago
kwsantiago requested a review from praveenperera July 22, 2026 22:16
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.
Editing the url or switching to Esplora reused the saved pin.
parse_custom_node now inherits it, only for an unchanged Electrum url.
Keeps both sides of the node.rs tests. Cargo.lock taken from master.
@kwsantiago
kwsantiago force-pushed the electrum-self-signed-certs branch from 1692ad1 to b0cfa7a Compare July 22, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow connecting to nodes using self signed SSL certs

2 participants