Skip to content

Commit 35f3bf4

Browse files
committed
feat(electrum): route onion servers through isolated Tor
1 parent 1ea7444 commit 35f3bf4

28 files changed

Lines changed: 1382 additions & 356 deletions

lib/core/electrum/adapters/electrum_servers_adapter.dart

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,33 @@
1+
import 'dart:io';
2+
13
import 'package:bb_mobile/core/electrum/domain/electrum_fallback_runner.dart';
24
import 'package:bb_mobile/core/electrum/domain/errors/electrum_fallback_exception.dart';
35
import 'package:bb_mobile/core/electrum/domain/ports/electrum_servers_port.dart';
6+
import 'package:bb_mobile/core/electrum/domain/ports/electrum_tor_session_port.dart';
47
import 'package:bb_mobile/core/electrum/domain/repositories/electrum_server_repository.dart';
58
import 'package:bb_mobile/core/electrum/domain/repositories/electrum_settings_repository.dart';
69
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_connection.dart';
710
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_network.dart';
11+
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_url.dart';
812
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
13+
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
14+
import 'package:bull_tor/tor.dart';
915

1016
/// Concrete [ElectrumServersPort]. Owns the only place in the app where the
11-
/// active server list is resolved, merged with electrum + Tor settings, and
17+
/// active server list is resolved, merged with Electrum + Orbot settings, and
1218
/// iterated — there is no other path consumers can take to reach an Electrum
1319
/// server, which is what enforces the R1/R2/R2a privacy rule by construction.
1420
class ElectrumServersAdapter implements ElectrumServersPort {
1521
final ElectrumServerRepository _serverRepository;
1622
final ElectrumSettingsRepository _settingsRepository;
1723
final SettingsRepository _appSettingsRepository;
24+
final ElectrumTorSessionPort _torSessionPort;
1825

1926
ElectrumServersAdapter({
2027
required this._serverRepository,
2128
required this._settingsRepository,
2229
required this._appSettingsRepository,
30+
required this._torSessionPort,
2331
});
2432

2533
@override
@@ -54,11 +62,6 @@ class ElectrumServersAdapter implements ElectrumServersPort {
5462
throw NoElectrumServersConfiguredException(network);
5563
}
5664

57-
// Tor proxy applies to Bitcoin only, never Liquid.
58-
final socks5 = (appSettings.useTorProxy && !network.isLiquid)
59-
? (settings.socks5 ?? '127.0.0.1:${appSettings.torProxyPort}')
60-
: settings.socks5;
61-
6265
final connections = servers
6366
.map(
6467
(server) => ElectrumConnection(
@@ -68,7 +71,7 @@ class ElectrumServersAdapter implements ElectrumServersPort {
6871
stopGap: settings.stopGap,
6972
validateDomain: settings.validateDomain,
7073
isCustom: server.isCustom,
71-
socks5: socks5,
74+
socks5: settings.socks5,
7275
),
7376
)
7477
.toList();
@@ -77,8 +80,77 @@ class ElectrumServersAdapter implements ElectrumServersPort {
7780
servers: connections,
7881
urlOf: (c) => c.url,
7982
isCustomOf: (c) => c.isCustom,
80-
operation: operation,
81-
isTransient: isTransient,
83+
operation: (connection) async {
84+
final ElectrumTorRoute? route;
85+
try {
86+
route = await _torSessionPort.open(
87+
network: network,
88+
serverUrl: connection.url,
89+
externalProxyEnabled: appSettings.useTorProxy,
90+
externalProxyPort: appSettings.torProxyPort,
91+
);
92+
} catch (_) {
93+
// Opening the route can fail on its own — an embedded bootstrap that
94+
// never completes is arguably the likeliest way an onion server
95+
// becomes unusable. That makes *this server* unroutable, not the whole
96+
// active set, so it has to surface as the type the fallback loop
97+
// already treats as transient. Otherwise a caller that narrows
98+
// `isTransient` to its own error rethrows immediately and never tries
99+
// the healthy clearnet default sitting next in the set.
100+
throw OnionServerWithoutTorException(connection.url);
101+
}
102+
try {
103+
// Precedence matters and predates this stack: an explicitly persisted
104+
// SOCKS setting wins over the Tor proxy toggle, which is the old
105+
// `settings.socks5 ?? torProxy` semantics. The onion route comes first
106+
// because it is the only one that can carry a hidden service.
107+
final routed = connection.withSocks5(
108+
route?.endpoint.authority ??
109+
connection.socks5 ??
110+
_clearnetProxy(network, appSettings),
111+
);
112+
// The chokepoint that makes the invariant unavoidable: not every
113+
// consumer goes through our socket connector — BDK and LWK open
114+
// their own — so refusing here is the only check they all share.
115+
if (_isUnroutableOnion(routed)) {
116+
throw OnionServerWithoutTorException(routed.url);
117+
}
118+
return await operation(routed);
119+
} finally {
120+
await route?.close();
121+
}
122+
},
123+
// An unroutable onion server is skipped, not fatal: the rest of the
124+
// active set may still be reachable. Callers narrowing `isTransient` to
125+
// their own error type must not turn that into a hard stop.
126+
isTransient: isTransient == null
127+
? null
128+
: (error) =>
129+
error is OnionServerWithoutTorException || isTransient(error),
82130
);
83131
}
132+
133+
/// The external proxy for a *clearnet* Bitcoin server, when one is configured.
134+
///
135+
/// Onion servers get a dedicated route from [_torSessionPort]; this covers
136+
/// everything else. Without it, enabling the Tor proxy would silently stop
137+
/// protecting the default Electrum servers — which are clearnet, and are
138+
/// exactly what a user hides their IP from by turning Orbot on. The setting
139+
/// has always been documented as applying to Bitcoin, not to onions only.
140+
///
141+
/// Liquid stays excluded, as it always has been.
142+
static String? _clearnetProxy(
143+
ElectrumServerNetwork network,
144+
SettingsEntity appSettings,
145+
) {
146+
if (network.isLiquid || !appSettings.useTorProxy) return null;
147+
return TorProxyEndpoint(
148+
host: InternetAddress.loopbackIPv4.address,
149+
port: appSettings.torProxyPort,
150+
).authority;
151+
}
152+
153+
static bool _isUnroutableOnion(ElectrumConnection connection) =>
154+
ElectrumServerUrl(connection.url).isOnion &&
155+
(connection.socks5?.isEmpty ?? true);
84156
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'dart:io';
2+
3+
import 'package:bb_mobile/core/electrum/domain/ports/electrum_tor_session_port.dart';
4+
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_network.dart';
5+
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_url.dart';
6+
import 'package:bull_tor/tor.dart';
7+
8+
final class ElectrumTorSessionAdapter implements ElectrumTorSessionPort {
9+
final Tor Function() _tor;
10+
11+
const ElectrumTorSessionAdapter(this._tor);
12+
13+
@override
14+
Future<ElectrumTorRoute?> open({
15+
required ElectrumServerNetwork network,
16+
required String serverUrl,
17+
required bool externalProxyEnabled,
18+
required int externalProxyPort,
19+
}) async {
20+
if (network.isLiquid || !ElectrumServerUrl(serverUrl).isOnion) return null;
21+
22+
if (externalProxyEnabled) {
23+
return ElectrumTorRoute(
24+
TorProxyEndpoint(
25+
host: InternetAddress.loopbackIPv4.address,
26+
port: externalProxyPort,
27+
),
28+
() async {},
29+
);
30+
}
31+
32+
final session = await _tor().embedded.sessions.open();
33+
return ElectrumTorRoute(session.endpoint, session.close);
34+
}
35+
}

0 commit comments

Comments
 (0)