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