CW-860 Fix status for http auth nodes (#1943)
cyan committed
Jan 20, 2025 at 16:52 UTC
aef71d16f145652febfbf16224e2142ea48d8b05
1 file changed
+174
-12
cw_core/lib/node.dart
+174
-12
@@ -1,13 +1,18 @@
1
import 'dart:io';
2
+import 'dart:math';
3
import 'package:cw_core/keyable.dart';
4
+import 'package:cw_core/utils/print_verbose.dart';
5
import 'dart:convert';
6
import 'package:http/http.dart' as http;
7
import 'package:hive/hive.dart';
8
import 'package:cw_core/hive_type_ids.dart';
9
import 'package:cw_core/wallet_type.dart';
10
import 'package:http/io_client.dart' as ioc;
11
+import 'dart:math' as math;
12
+import 'package:convert/convert.dart';
13
+import 'package:crypto/crypto.dart' as crypto;
14
10
-// import 'package:tor/tor.dart';
15
+import 'package:crypto/crypto.dart';
16
17
part 'node.g.dart';
18
@@ -170,34 +175,43 @@ class Node extends HiveObject with Keyable {
175
}
176
177
Future<bool> requestMoneroNode() async {
173
- if (uri.toString().contains(".onion") || useSocksProxy) {
178
+ if (useSocksProxy) {
179
return await requestNodeWithProxy();
180
}
181
+
182
+
183
final path = '/json_rpc';
184
final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path);
178
- final realm = 'monero-rpc';
185
final body = {'jsonrpc': '2.0', 'id': '0', 'method': 'get_info'};
186
187
+
188
try {
189
final authenticatingClient = HttpClient();
183
-
190
authenticatingClient.badCertificateCallback =
191
((X509Certificate cert, String host, int port) => true);
192
187
- authenticatingClient.addCredentials(
188
- rpcUri,
189
- realm,
190
- HttpClientDigestCredentials(login ?? '', password ?? ''),
191
- );
193
194
final http.Client client = ioc.IOClient(authenticatingClient);
195
196
+ final jsonBody = json.encode(body);
197
+
198
final response = await client.post(
199
rpcUri,
200
headers: {'Content-Type': 'application/json'},
198
- body: json.encode(body),
201
+ body: jsonBody,
202
);
200
- client.close();
203
+ // Check if we received a 401 Unauthorized response
204
+ if (response.statusCode == 401) {
205
+ final daemonRpc = DaemonRpc(
206
+ rpcUri.toString(),
207
+ username: login??'',
208
+ password: password??'',
209
+ );
210
+ final response = await daemonRpc.call('get_info', {});
211
+ return !(response['offline'] as bool);
212
+ }
213
+
214
+ printV("node check response: ${response.body}");
215
216
if ((response.body.contains("400 Bad Request") // Some other generic error
217
||
@@ -225,7 +239,8 @@ class Node extends HiveObject with Keyable {
239
240
final resBody = json.decode(response.body) as Map<String, dynamic>;
241
return !(resBody['result']['offline'] as bool);
228
- } catch (_) {
242
+ } catch (e) {
243
+ printV("error: $e");
244
return false;
245
}
246
}
@@ -316,3 +331,150 @@ class Node extends HiveObject with Keyable {
331
}
332
}
333
}
334
+
335
+/// https://github.com/ManyMath/digest_auth/
336
+/// HTTP Digest authentication.
337
+///
338
+/// Adapted from https://github.com/dart-lang/http/issues/605#issue-963962341.
339
+///
340
+/// Created because http_auth was not working for Monero daemon RPC responses.
341
+class DigestAuth {
342
+ final String username;
343
+ final String password;
344
+ String? realm;
345
+ String? nonce;
346
+ String? uri;
347
+ String? qop = "auth";
348
+ int _nonceCount = 0;
349
+
350
+ DigestAuth(this.username, this.password);
351
+
352
+ /// Initialize Digest parameters from the `WWW-Authenticate` header.
353
+ void initFromAuthorizationHeader(String authInfo) {
354
+ final Map<String, String>? values = _splitAuthenticateHeader(authInfo);
355
+ if (values != null) {
356
+ realm = values['realm'];
357
+ // Check if the nonce has changed.
358
+ if (nonce != values['nonce']) {
359
+ nonce = values['nonce'];
360
+ _nonceCount = 0; // Reset nonce count when nonce changes.
361
+ }
362
+ }
363
+ }
364
+
365
+ /// Generate the Digest Authorization header.
366
+ String getAuthString(String method, String uri) {
367
+ this.uri = uri;
368
+ _nonceCount++;
369
+ String cnonce = _computeCnonce();
370
+ String nc = _formatNonceCount(_nonceCount);
371
+
372
+ String ha1 = md5Hash("$username:$realm:$password");
373
+ String ha2 = md5Hash("$method:$uri");
374
+ String response = md5Hash("$ha1:$nonce:$nc:$cnonce:$qop:$ha2");
375
+
376
+ return 'Digest username="$username", realm="$realm", nonce="$nonce", uri="$uri", qop=$qop, nc=$nc, cnonce="$cnonce", response="$response"';
377
+ }
378
+
379
+ /// Helper to parse the `WWW-Authenticate` header.
380
+ Map<String, String>? _splitAuthenticateHeader(String? header) {
381
+ if (header == null || !header.startsWith('Digest ')) {
382
+ return null;
383
+ }
384
+ String token = header.substring(7); // Remove 'Digest '.
385
+ final Map<String, String> result = {};
386
+
387
+ final components = token.split(',').map((token) => token.trim());
388
+ for (final component in components) {
389
+ final kv = component.split('=');
390
+ final key = kv[0];
391
+ final value = kv.sublist(1).join('=').replaceAll('"', '');
392
+ result[key] = value;
393
+ }
394
+ return result;
395
+ }
396
+
397
+ /// Helper to compute a random cnonce.
398
+ String _computeCnonce() {
399
+ final math.Random rnd = math.Random();
400
+ final List<int> values = List<int>.generate(16, (i) => rnd.nextInt(256));
401
+ return hex.encode(values);
402
+ }
403
+
404
+ /// Helper to format the nonce count.
405
+ String _formatNonceCount(int count) =>
406
+ count.toRadixString(16).padLeft(8, '0');
407
+
408
+ /// Compute the MD5 hash of a string.
409
+ String md5Hash(String input) {
410
+ return md5.convert(utf8.encode(input)).toString();
411
+ }
412
+}
413
+
414
+class DaemonRpc {
415
+ final String rpcUrl;
416
+ final String username;
417
+ final String password;
418
+
419
+ DaemonRpc(this.rpcUrl, {required this.username, required this.password});
420
+
421
+ /// Perform a JSON-RPC call with Digest Authentication.
422
+ Future<Map<String, dynamic>> call(
423
+ String method, Map<String, dynamic> params) async {
424
+ final http.Client client = http.Client();
425
+ final DigestAuth digestAuth = DigestAuth(username, password);
426
+
427
+ // Initial request to get the `WWW-Authenticate` header.
428
+ final initialResponse = await client.post(
429
+ Uri.parse(rpcUrl),
430
+ headers: {
431
+ 'Content-Type': 'application/json',
432
+ },
433
+ body: jsonEncode({
434
+ 'jsonrpc': '2.0',
435
+ 'id': '0',
436
+ 'method': method,
437
+ 'params': params,
438
+ }),
439
+ );
440
+
441
+ if (initialResponse.statusCode != 401 ||
442
+ !initialResponse.headers.containsKey('www-authenticate')) {
443
+ throw Exception('Unexpected response: ${initialResponse.body}');
444
+ }
445
+
446
+ // Extract Digest details from `WWW-Authenticate` header.
447
+ final String authInfo = initialResponse.headers['www-authenticate']!;
448
+ digestAuth.initFromAuthorizationHeader(authInfo);
449
+
450
+ // Create Authorization header for the second request.
451
+ String uri = Uri.parse(rpcUrl).path;
452
+ String authHeader = digestAuth.getAuthString('POST', uri);
453
+
454
+ // Make the authenticated request.
455
+ final authenticatedResponse = await client.post(
456
+ Uri.parse(rpcUrl),
457
+ headers: {
458
+ 'Content-Type': 'application/json',
459
+ 'Authorization': authHeader,
460
+ },
461
+ body: jsonEncode({
462
+ 'jsonrpc': '2.0',
463
+ 'id': '0',
464
+ 'method': method,
465
+ 'params': params,
466
+ }),
467
+ );
468
+
469
+ if (authenticatedResponse.statusCode != 200) {
470
+ throw Exception('RPC call failed: ${authenticatedResponse.body}');
471
+ }
472
+
473
+ final Map<String, dynamic> result = jsonDecode(authenticatedResponse.body) as Map<String, dynamic>;
474
+ if (result['error'] != null) {
475
+ throw Exception('RPC Error: ${result['error']}');
476
+ }
477
+
478
+ return result['result'] as Map<String, dynamic>;
479
+ }
480
+}
\ No newline at end of file