From fd6d698017e06b6bba3883c4edd803a1aacd30eb Mon Sep 17 00:00:00 2001 From: Czarek Nakamoto Date: Mon, 6 Jan 2025 16:08:15 +0100 Subject: [PATCH] CW-860 Fix status for http auth nodes --- cw_core/lib/node.dart | 186 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 174 insertions(+), 12 deletions(-) diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index 7d0c2411f1..7b32940432 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -1,13 +1,18 @@ import 'dart:io'; +import 'dart:math'; import 'package:cw_core/keyable.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:hive/hive.dart'; import 'package:cw_core/hive_type_ids.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:http/io_client.dart' as ioc; +import 'dart:math' as math; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart' as crypto; -// import 'package:tor/tor.dart'; +import 'package:crypto/crypto.dart'; part 'node.g.dart'; @@ -170,34 +175,43 @@ class Node extends HiveObject with Keyable { } Future requestMoneroNode() async { - if (uri.toString().contains(".onion") || useSocksProxy) { + if (useSocksProxy) { return await requestNodeWithProxy(); } + + final path = '/json_rpc'; final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path); - final realm = 'monero-rpc'; final body = {'jsonrpc': '2.0', 'id': '0', 'method': 'get_info'}; + try { final authenticatingClient = HttpClient(); - authenticatingClient.badCertificateCallback = ((X509Certificate cert, String host, int port) => true); - authenticatingClient.addCredentials( - rpcUri, - realm, - HttpClientDigestCredentials(login ?? '', password ?? ''), - ); final http.Client client = ioc.IOClient(authenticatingClient); + final jsonBody = json.encode(body); + final response = await client.post( rpcUri, headers: {'Content-Type': 'application/json'}, - body: json.encode(body), + body: jsonBody, ); - client.close(); + // Check if we received a 401 Unauthorized response + if (response.statusCode == 401) { + final daemonRpc = DaemonRpc( + rpcUri.toString(), + username: login??'', + password: password??'', + ); + final response = await daemonRpc.call('get_info', {}); + return !(response['offline'] as bool); + } + + printV("node check response: ${response.body}"); if ((response.body.contains("400 Bad Request") // Some other generic error || @@ -225,7 +239,8 @@ class Node extends HiveObject with Keyable { final resBody = json.decode(response.body) as Map; return !(resBody['result']['offline'] as bool); - } catch (_) { + } catch (e) { + printV("error: $e"); return false; } } @@ -316,3 +331,150 @@ class Node extends HiveObject with Keyable { } } } + +/// https://github.com/ManyMath/digest_auth/ +/// HTTP Digest authentication. +/// +/// Adapted from https://github.com/dart-lang/http/issues/605#issue-963962341. +/// +/// Created because http_auth was not working for Monero daemon RPC responses. +class DigestAuth { + final String username; + final String password; + String? realm; + String? nonce; + String? uri; + String? qop = "auth"; + int _nonceCount = 0; + + DigestAuth(this.username, this.password); + + /// Initialize Digest parameters from the `WWW-Authenticate` header. + void initFromAuthorizationHeader(String authInfo) { + final Map? values = _splitAuthenticateHeader(authInfo); + if (values != null) { + realm = values['realm']; + // Check if the nonce has changed. + if (nonce != values['nonce']) { + nonce = values['nonce']; + _nonceCount = 0; // Reset nonce count when nonce changes. + } + } + } + + /// Generate the Digest Authorization header. + String getAuthString(String method, String uri) { + this.uri = uri; + _nonceCount++; + String cnonce = _computeCnonce(); + String nc = _formatNonceCount(_nonceCount); + + String ha1 = md5Hash("$username:$realm:$password"); + String ha2 = md5Hash("$method:$uri"); + String response = md5Hash("$ha1:$nonce:$nc:$cnonce:$qop:$ha2"); + + return 'Digest username="$username", realm="$realm", nonce="$nonce", uri="$uri", qop=$qop, nc=$nc, cnonce="$cnonce", response="$response"'; + } + + /// Helper to parse the `WWW-Authenticate` header. + Map? _splitAuthenticateHeader(String? header) { + if (header == null || !header.startsWith('Digest ')) { + return null; + } + String token = header.substring(7); // Remove 'Digest '. + final Map result = {}; + + final components = token.split(',').map((token) => token.trim()); + for (final component in components) { + final kv = component.split('='); + final key = kv[0]; + final value = kv.sublist(1).join('=').replaceAll('"', ''); + result[key] = value; + } + return result; + } + + /// Helper to compute a random cnonce. + String _computeCnonce() { + final math.Random rnd = math.Random(); + final List values = List.generate(16, (i) => rnd.nextInt(256)); + return hex.encode(values); + } + + /// Helper to format the nonce count. + String _formatNonceCount(int count) => + count.toRadixString(16).padLeft(8, '0'); + + /// Compute the MD5 hash of a string. + String md5Hash(String input) { + return md5.convert(utf8.encode(input)).toString(); + } +} + +class DaemonRpc { + final String rpcUrl; + final String username; + final String password; + + DaemonRpc(this.rpcUrl, {required this.username, required this.password}); + + /// Perform a JSON-RPC call with Digest Authentication. + Future> call( + String method, Map params) async { + final http.Client client = http.Client(); + final DigestAuth digestAuth = DigestAuth(username, password); + + // Initial request to get the `WWW-Authenticate` header. + final initialResponse = await client.post( + Uri.parse(rpcUrl), + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'jsonrpc': '2.0', + 'id': '0', + 'method': method, + 'params': params, + }), + ); + + if (initialResponse.statusCode != 401 || + !initialResponse.headers.containsKey('www-authenticate')) { + throw Exception('Unexpected response: ${initialResponse.body}'); + } + + // Extract Digest details from `WWW-Authenticate` header. + final String authInfo = initialResponse.headers['www-authenticate']!; + digestAuth.initFromAuthorizationHeader(authInfo); + + // Create Authorization header for the second request. + String uri = Uri.parse(rpcUrl).path; + String authHeader = digestAuth.getAuthString('POST', uri); + + // Make the authenticated request. + final authenticatedResponse = await client.post( + Uri.parse(rpcUrl), + headers: { + 'Content-Type': 'application/json', + 'Authorization': authHeader, + }, + body: jsonEncode({ + 'jsonrpc': '2.0', + 'id': '0', + 'method': method, + 'params': params, + }), + ); + + if (authenticatedResponse.statusCode != 200) { + throw Exception('RPC call failed: ${authenticatedResponse.body}'); + } + + final Map result = jsonDecode(authenticatedResponse.body) as Map; + if (result['error'] != null) { + throw Exception('RPC Error: ${result['error']}'); + } + + return result['result'] as Map; + } +} \ No newline at end of file