Skip to content

Commit 6f13590

Browse files
committed
feat(electrum): route onion servers through isolated Tor
1 parent 4e72124 commit 6f13590

28 files changed

Lines changed: 1239 additions & 357 deletions

lib/core/electrum/adapters/electrum_servers_adapter.dart

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
11
import 'package:bb_mobile/core/electrum/domain/electrum_fallback_runner.dart';
22
import 'package:bb_mobile/core/electrum/domain/errors/electrum_fallback_exception.dart';
33
import 'package:bb_mobile/core/electrum/domain/ports/electrum_servers_port.dart';
4+
import 'package:bb_mobile/core/electrum/domain/ports/electrum_tor_session_port.dart';
45
import 'package:bb_mobile/core/electrum/domain/repositories/electrum_server_repository.dart';
56
import 'package:bb_mobile/core/electrum/domain/repositories/electrum_settings_repository.dart';
67
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_connection.dart';
78
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_network.dart';
9+
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_url.dart';
810
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
911

1012
/// Concrete [ElectrumServersPort]. Owns the only place in the app where the
11-
/// active server list is resolved, merged with electrum + Tor settings, and
13+
/// active server list is resolved, merged with Electrum + Orbot settings, and
1214
/// iterated — there is no other path consumers can take to reach an Electrum
1315
/// server, which is what enforces the R1/R2/R2a privacy rule by construction.
1416
class ElectrumServersAdapter implements ElectrumServersPort {
1517
final ElectrumServerRepository _serverRepository;
1618
final ElectrumSettingsRepository _settingsRepository;
1719
final SettingsRepository _appSettingsRepository;
20+
final ElectrumTorSessionPort _torSessionPort;
1821

1922
ElectrumServersAdapter({
2023
required this._serverRepository,
2124
required this._settingsRepository,
2225
required this._appSettingsRepository,
26+
required this._torSessionPort,
2327
});
2428

2529
@override
@@ -54,11 +58,6 @@ class ElectrumServersAdapter implements ElectrumServersPort {
5458
throw NoElectrumServersConfiguredException(network);
5559
}
5660

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-
6261
final connections = servers
6362
.map(
6463
(server) => ElectrumConnection(
@@ -68,7 +67,7 @@ class ElectrumServersAdapter implements ElectrumServersPort {
6867
stopGap: settings.stopGap,
6968
validateDomain: settings.validateDomain,
7069
isCustom: server.isCustom,
71-
socks5: socks5,
70+
socks5: settings.socks5,
7271
),
7372
)
7473
.toList();
@@ -77,8 +76,39 @@ class ElectrumServersAdapter implements ElectrumServersPort {
7776
servers: connections,
7877
urlOf: (c) => c.url,
7978
isCustomOf: (c) => c.isCustom,
80-
operation: operation,
81-
isTransient: isTransient,
79+
operation: (connection) async {
80+
final route = await _torSessionPort.open(
81+
network: network,
82+
serverUrl: connection.url,
83+
externalProxyEnabled: appSettings.useTorProxy,
84+
externalProxyPort: appSettings.torProxyPort,
85+
);
86+
try {
87+
final routed = connection.withSocks5(
88+
route?.endpoint.authority ?? connection.socks5,
89+
);
90+
// The chokepoint that makes the invariant unavoidable: not every
91+
// consumer goes through our socket connector — BDK and LWK open
92+
// their own — so refusing here is the only check they all share.
93+
if (_isUnroutableOnion(routed)) {
94+
throw OnionServerWithoutTorException(routed.url);
95+
}
96+
return await operation(routed);
97+
} finally {
98+
await route?.close();
99+
}
100+
},
101+
// An unroutable onion server is skipped, not fatal: the rest of the
102+
// active set may still be reachable. Callers narrowing `isTransient` to
103+
// their own error type must not turn that into a hard stop.
104+
isTransient: isTransient == null
105+
? null
106+
: (error) =>
107+
error is OnionServerWithoutTorException || isTransient(error),
82108
);
83109
}
110+
111+
static bool _isUnroutableOnion(ElectrumConnection connection) =>
112+
ElectrumServerUrl(connection.url).isOnion &&
113+
(connection.socks5?.isEmpty ?? true);
84114
}
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+
}
Lines changed: 35 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import 'dart:async';
21
import 'dart:convert';
3-
import 'dart:io';
42

3+
import 'package:bb_mobile/core/electrum/data/electrum_socket_connector.dart';
54
import 'package:bb_mobile/core/electrum/domain/ports/server_status_port.dart';
65
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_network.dart';
76
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_status.dart';
7+
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_url.dart';
88
import 'package:bb_mobile/core/utils/logger.dart';
9+
import 'package:bull_tor/tor.dart';
910

1011
class ServerStatusAdapter implements ServerStatusPort {
11-
const ServerStatusAdapter();
12+
final ElectrumSocketConnector _socketConnector;
13+
14+
const ServerStatusAdapter(this._socketConnector);
1215

1316
/// Bitcoin pizza day — first real-world BTC purchase, May 22 2010.
1417
static const _bitcoinMainnetProbeTxid =
@@ -22,8 +25,7 @@ class ServerStatusAdapter implements ServerStatusPort {
2225
Future<ElectrumServerStatus> checkSocket({
2326
required String url,
2427
int? timeout,
25-
bool useTorProxy = false,
26-
int torProxyPort = 9050,
28+
TorProxyEndpoint? proxyEndpoint,
2729
}) async {
2830
try {
2931
if (url.isEmpty) return ElectrumServerStatus.unknown;
@@ -33,23 +35,18 @@ class ServerStatusAdapter implements ServerStatusPort {
3335

3436
final effectiveTimeout = _resolveTimeout(uri, timeout);
3537

36-
final isConnectable = useTorProxy
37-
? await _checkThroughSocks5(
38-
uri: uri,
39-
proxyPort: torProxyPort,
40-
timeoutSeconds: effectiveTimeout,
41-
)
42-
: await _checkRawSocket(uri: uri, timeoutSeconds: effectiveTimeout);
43-
44-
return isConnectable
45-
? ElectrumServerStatus.online
46-
: ElectrumServerStatus.offline;
47-
} catch (e) {
48-
log.severe(
49-
message: 'Error checking server socket for $url',
50-
error: e,
51-
trace: StackTrace.current,
38+
final socket = await _socketConnector.connect(
39+
server: uri,
40+
proxy: proxyEndpoint,
41+
timeout: Duration(seconds: effectiveTimeout),
42+
allowBadCertificate: true,
5243
);
44+
socket.destroy();
45+
return ElectrumServerStatus.online;
46+
} catch (e) {
47+
// A server we cannot reach is the expected answer of this check, not a
48+
// fault: `severe` would report every offline server to Sentry.
49+
log.warning('Socket check failed for $url - $e');
5350
return ElectrumServerStatus.offline;
5451
}
5552
}
@@ -60,6 +57,7 @@ class ServerStatusAdapter implements ServerStatusPort {
6057
required ElectrumServerNetwork network,
6158
required bool validateDomain,
6259
int? timeout,
60+
TorProxyEndpoint? proxyEndpoint,
6361
}) async {
6462
try {
6563
if (url.isEmpty) return ElectrumServerStatus.unknown;
@@ -81,6 +79,7 @@ class ServerStatusAdapter implements ServerStatusPort {
8179
request: request,
8280
timeoutSeconds: effectiveTimeout,
8381
validateDomain: validateDomain,
82+
proxyEndpoint: proxyEndpoint,
8483
);
8584

8685
if (response.isEmpty) return ElectrumServerStatus.offline;
@@ -126,34 +125,30 @@ class ServerStatusAdapter implements ServerStatusPort {
126125
/// Onion addresses need longer timeouts due to Tor circuit building.
127126
/// Default: 5 seconds for clearnet, 30 seconds for .onion addresses.
128127
int _resolveTimeout(Uri uri, int? timeout) {
129-
final isOnion = uri.host.endsWith('.onion');
128+
final isOnion = ElectrumServerUrl.isOnionHost(uri.host);
130129
return timeout ?? (isOnion ? 30 : 5);
131130
}
132131

133-
/// Sends a JSON-RPC request and returns the raw response line.
134-
/// [SecureSocket] extends [Socket], so both branches share the same
135-
/// write/read logic after construction.
132+
/// Sends a JSON-RPC request and returns the raw response line. Plain, TLS
133+
/// and proxied sockets all arrive as a [Socket], so the read/write path
134+
/// below is the same for every transport.
136135
Future<String> _sendRequest({
137136
required Uri uri,
138137
required String request,
139138
required int timeoutSeconds,
140139
required bool validateDomain,
140+
TorProxyEndpoint? proxyEndpoint,
141141
}) async {
142-
// A null onBadCertificate callback enforces strict CA validation. The
143-
// flag comes from the user's electrum settings, so the probe accepts
144-
// exactly the certificates the BDK/LWK sync would accept.
145-
final Socket socket = uri.scheme == 'ssl'
146-
? await SecureSocket.connect(
147-
uri.host,
148-
uri.port,
149-
timeout: Duration(seconds: timeoutSeconds),
150-
onBadCertificate: validateDomain ? null : (_) => true,
151-
)
152-
: await Socket.connect(
153-
uri.host,
154-
uri.port,
155-
timeout: Duration(seconds: timeoutSeconds),
156-
);
142+
// Certificates follow the user's `validateDomain` setting rather than
143+
// being blanket-accepted: probing laxer than the sync reports a server
144+
// online that the sync will then refuse, and probing stricter hides a
145+
// server that would have worked.
146+
final socket = await _socketConnector.connect(
147+
server: uri,
148+
proxy: proxyEndpoint,
149+
timeout: Duration(seconds: timeoutSeconds),
150+
allowBadCertificate: !validateDomain,
151+
);
157152

158153
try {
159154
socket.write(request);
@@ -167,127 +162,4 @@ class ServerStatusAdapter implements ServerStatusPort {
167162
socket.destroy();
168163
}
169164
}
170-
171-
Future<bool> _checkRawSocket({
172-
required Uri uri,
173-
required int timeoutSeconds,
174-
}) async {
175-
try {
176-
final socket = await Socket.connect(
177-
uri.host,
178-
uri.port,
179-
timeout: Duration(seconds: timeoutSeconds),
180-
);
181-
socket.destroy();
182-
return true;
183-
} on SocketException catch (e) {
184-
log.warning('Socket connection failed for $uri - $e');
185-
return false;
186-
} catch (e) {
187-
log.severe(
188-
message: 'Unexpected error checking socket for $uri',
189-
error: e,
190-
trace: StackTrace.current,
191-
);
192-
return false;
193-
}
194-
}
195-
196-
/// Checks connectivity through a SOCKS5 proxy (used for Tor).
197-
Future<bool> _checkThroughSocks5({
198-
required Uri uri,
199-
required int proxyPort,
200-
required int timeoutSeconds,
201-
}) async {
202-
Socket? socket;
203-
StreamSubscription<List<int>>? subscription;
204-
try {
205-
socket = await Socket.connect(
206-
'127.0.0.1',
207-
proxyPort,
208-
timeout: Duration(seconds: timeoutSeconds),
209-
);
210-
211-
final handshakeCompleter = Completer<List<int>>();
212-
final connectCompleter = Completer<List<int>>();
213-
var isHandshakeDone = false;
214-
215-
subscription = socket.listen(
216-
(List<int> data) {
217-
if (!isHandshakeDone) {
218-
handshakeCompleter.complete(data);
219-
isHandshakeDone = true;
220-
} else {
221-
connectCompleter.complete(data);
222-
}
223-
},
224-
onError: (Object error) {
225-
if (!handshakeCompleter.isCompleted) {
226-
handshakeCompleter.completeError(error);
227-
}
228-
if (!connectCompleter.isCompleted) {
229-
connectCompleter.completeError(error);
230-
}
231-
},
232-
cancelOnError: true,
233-
);
234-
235-
// SOCKS5 handshake: version 5, 1 auth method (no auth)
236-
socket.add([0x05, 0x01, 0x00]);
237-
238-
final handshakeResponse = await handshakeCompleter.future.timeout(
239-
Duration(seconds: timeoutSeconds),
240-
);
241-
242-
if (handshakeResponse.length < 2 || handshakeResponse[0] != 0x05) {
243-
log.warning('Invalid SOCKS5 handshake response');
244-
return false;
245-
}
246-
247-
socket.add(_buildSocks5ConnectRequest(uri));
248-
249-
final connectResponse = await connectCompleter.future.timeout(
250-
Duration(seconds: timeoutSeconds),
251-
);
252-
253-
if (connectResponse.length < 2 ||
254-
connectResponse[0] != 0x05 ||
255-
connectResponse[1] != 0x00) {
256-
log.severe(
257-
error: Exception('SOCKS5 connection failed'),
258-
trace: StackTrace.current,
259-
);
260-
return false;
261-
}
262-
263-
return true;
264-
} catch (e) {
265-
log.severe(
266-
message: 'SOCKS5 connection check failed for $uri',
267-
error: e,
268-
trace: StackTrace.current,
269-
);
270-
return false;
271-
} finally {
272-
await subscription?.cancel();
273-
socket?.destroy();
274-
}
275-
}
276-
277-
/// Builds a SOCKS5 CONNECT request.
278-
/// Format: [version, command, reserved, address_type, address, port]
279-
List<int> _buildSocks5ConnectRequest(Uri uri) {
280-
final request = <int>[
281-
0x05, // SOCKS version 5
282-
0x01, // CONNECT command
283-
0x00, // Reserved
284-
0x03, // Address type: domain name
285-
];
286-
final hostBytes = uri.host.codeUnits;
287-
request.add(hostBytes.length);
288-
request.addAll(hostBytes);
289-
request.add((uri.port >> 8) & 0xFF);
290-
request.add(uri.port & 0xFF);
291-
return request;
292-
}
293165
}

0 commit comments

Comments
 (0)