| 1 | import 'dart:convert'; |
| 2 | import 'dart:io'; |
| 3 | import 'dart:math' as math; |
| 4 | |
| 5 | import 'package:blockchain_utils/hex/hex.dart'; |
| 6 | import 'package:cw_core/node_list.dart'; |
| 7 | import 'package:cw_core/utils/print_verbose.dart'; |
| 8 | import 'package:cw_core/utils/proxy_socket/abstract.dart'; |
| 9 | import 'package:cw_core/utils/proxy_wrapper.dart'; |
| 10 | import 'package:cw_core/wallet_type.dart'; |
| 11 | import 'package:crypto/crypto.dart'; |
| 12 | import 'package:sqflite/sqflite.dart'; |
| 13 | |
| 14 | import 'db/sqlite.dart'; |
| 15 | |
| 16 | Uri createUriFromElectrumAddress(String address, String path) => |
| 17 | Uri.tryParse('tcp://$address$path')!; |
| 18 | |
| 19 | Future<void> validateBuiltinNodes() async { |
| 20 | // ensures nodes stored as builtin in the db correspond to the nodes in the .yml files |
| 21 | |
| 22 | final builtinFromDb = await Node.getAllBuiltin(); |
| 23 | final builtinFromList = await loadAllDefaultNodes(); |
| 24 | |
| 25 | for (final listNode in builtinFromList) { |
| 26 | // preserve proxy settings from user |
| 27 | try { |
| 28 | final matchedDbNode = builtinFromDb.firstWhere((dbNode) => dbNode.uri == listNode.uri); |
| 29 | listNode.socksProxyAddress = matchedDbNode.socksProxyAddress; |
| 30 | } catch (e) {} |
| 31 | } |
| 32 | |
| 33 | final dbSet = builtinFromDb.toSet(); |
| 34 | final listSet = builtinFromList.toSet(); |
| 35 | |
| 36 | final nodesToDelete = dbSet.difference(listSet); |
| 37 | final nodesToAdd = listSet.difference(dbSet); |
| 38 | |
| 39 | for (final node in nodesToDelete) await node.delete(); |
| 40 | for (final node in nodesToAdd) await node.save(); |
| 41 | } |
| 42 | |
| 43 | class Node { |
| 44 | Node({ |
| 45 | this.id = 0, |
| 46 | this.label, |
| 47 | this.login, |
| 48 | this.password, |
| 49 | this.useSSL, |
| 50 | this.isPow = false, |
| 51 | this.trusted = false, |
| 52 | this.socksProxyAddress, |
| 53 | this.path = '', |
| 54 | this.isEnabledForAutoSwitching = false, |
| 55 | this.isOfficial = false, |
| 56 | this.isBuiltin = false, |
| 57 | this.isDefault = false, |
| 58 | String? uri, |
| 59 | WalletType? type, |
| 60 | }) { |
| 61 | if (uri != null) { |
| 62 | uriRaw = uri; |
| 63 | } |
| 64 | if (type != null) { |
| 65 | this.type = type; |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | @override |
| 70 | String toString() { |
| 71 | return """Node( |
| 72 | uriRaw: $uriRaw, |
| 73 | path: $path, |
| 74 | login: $login, |
| 75 | password: $password, |
| 76 | useSSL: $useSSL, |
| 77 | trusted: $trusted, |
| 78 | socksProxyAddress: $socksProxyAddress, |
| 79 | isEnabledForAutoSwitching: $isEnabledForAutoSwitching, |
| 80 | isOfficial: $isOfficial, |
| 81 | isBuiltin: $isBuiltin, |
| 82 | isDefault: $isDefault |
| 83 | })"""; |
| 84 | } |
| 85 | |
| 86 | Node.fromMap(Map<String, Object?> map) |
| 87 | : id = (map[selfIdColumn] ?? 0) as int, |
| 88 | uriRaw = map['uri'] as String? ?? '', |
| 89 | path = map['path'] as String? ?? '', |
| 90 | login = map['login'] as String?, |
| 91 | label = map['label'] as String?, |
| 92 | password = map['password'] as String?, |
| 93 | isPow = _getBoolFromDB(map['isPow']), |
| 94 | useSSL = _getBoolFromDB(map['useSSL']), |
| 95 | typeRaw = (map["typeRaw"] ?? 0) as int, |
| 96 | trusted = _getBoolFromDB(map['trusted']), |
| 97 | socksProxyAddress = map['socksProxyAddress'] as String?, |
| 98 | isEnabledForAutoSwitching = _getBoolFromDB(map['isEnabledForAutoSwitching']), |
| 99 | isOfficial = _getBoolFromDB(map['isOfficial']), |
| 100 | isBuiltin = _getBoolFromDB(map['isBuiltin']), |
| 101 | isDefault = _getBoolFromDB(map['isDefault']); |
| 102 | |
| 103 | static bool _getBoolFromDB(value, {bool? defaultValue}) { |
| 104 | if (value is bool) { |
| 105 | return value; |
| 106 | } else if (value is int) { |
| 107 | return value == 1; |
| 108 | } else { |
| 109 | return defaultValue ?? false; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | Map<String, dynamic> toMap() { |
| 114 | return { |
| 115 | selfIdColumn: id, |
| 116 | 'uri': uriRaw, |
| 117 | 'path': path, |
| 118 | 'login': login, |
| 119 | "label": label, |
| 120 | 'password': password, |
| 121 | "isPow": isPow ? 1 : 0, |
| 122 | 'useSSL': (useSSL ?? false) ? 1 : 0, |
| 123 | "typeRaw": typeRaw, |
| 124 | 'trusted': trusted ? 1 : 0, |
| 125 | 'socksProxyAddress': socksProxyAddress, |
| 126 | 'isEnabledForAutoSwitching': isEnabledForAutoSwitching ? 1 : 0, |
| 127 | "isOfficial": isOfficial ? 1 : 0, |
| 128 | "isBuiltin": isBuiltin ? 1 : 0, |
| 129 | "isDefault": isDefault ? 1 : 0 |
| 130 | }; |
| 131 | } |
| 132 | |
| 133 | Node.fromUri(Uri uri, WalletType type) |
| 134 | : id = 0, |
| 135 | uriRaw = |
| 136 | "${uri.host}${uri.hasPort ? ':${uri.port}' : (uri.queryParameters['port']?.isNotEmpty == true ? ':${uri.queryParameters['port']}' : '')}", |
| 137 | path = uri.path, |
| 138 | login = uri.userInfo.contains(':') |
| 139 | ? uri.userInfo.split(':')[0] |
| 140 | : uri.queryParameters['username'], |
| 141 | password = uri.userInfo.contains(':') |
| 142 | ? uri.userInfo.split(':')[1] |
| 143 | : uri.queryParameters['password'], |
| 144 | useSSL = uri.queryParameters['protocol'] == 'https', |
| 145 | trusted = uri.queryParameters['trusted'] == 'true', |
| 146 | isPow = false, |
| 147 | isEnabledForAutoSwitching = false, |
| 148 | isOfficial = false, |
| 149 | isBuiltin = false, |
| 150 | isDefault = false, |
| 151 | typeRaw = serializeToInt(type); |
| 152 | |
| 153 | Future<int> delete() async { |
| 154 | return await db!.delete(tableName, where: '${selfIdColumn} = ?', whereArgs: [id]); |
| 155 | } |
| 156 | |
| 157 | static Future<int> deleteAll() async { |
| 158 | return await db!.delete(tableName, where: "isPow = ?", whereArgs: [0]); |
| 159 | } |
| 160 | |
| 161 | static Future<int> deleteAllPow() async { |
| 162 | return await db!.delete(tableName, where: "isPow = ?", whereArgs: [1]); |
| 163 | } |
| 164 | |
| 165 | Future<int> save() async { |
| 166 | final json = toMap(); |
| 167 | if (json[selfIdColumn] == 0) { |
| 168 | json[selfIdColumn] = null; |
| 169 | } |
| 170 | id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace); |
| 171 | return id; |
| 172 | } |
| 173 | |
| 174 | static Future<List<Node>> selectList(String where, List<dynamic> whereArgs, |
| 175 | {String? orderBy}) async { |
| 176 | if (orderBy == null) { |
| 177 | orderBy = selfIdColumn; |
| 178 | } |
| 179 | final list = await db!.query( |
| 180 | tableName, |
| 181 | where: where.isNotEmpty ? where : "1 = 1", |
| 182 | whereArgs: whereArgs.isNotEmpty ? whereArgs : null, |
| 183 | orderBy: orderBy, |
| 184 | ); |
| 185 | return List.generate(list.length, (index) => Node.fromMap(list[index])); |
| 186 | } |
| 187 | |
| 188 | static Future<Node?> select(String where, List<dynamic> whereArgs, {String? orderBy}) async { |
| 189 | if (orderBy == null) { |
| 190 | orderBy = selfIdColumn; |
| 191 | } |
| 192 | final list = await db!.query( |
| 193 | tableName, |
| 194 | where: where.isNotEmpty ? where : "1 = 1", |
| 195 | whereArgs: whereArgs.isNotEmpty ? whereArgs : null, |
| 196 | orderBy: orderBy, |
| 197 | ); |
| 198 | return list.isEmpty ? null : Node.fromMap(list.first); |
| 199 | } |
| 200 | |
| 201 | static Future<List<Node>> getAll() async { |
| 202 | return selectList("isPow = ?", [0]); |
| 203 | } |
| 204 | |
| 205 | static Future<List<Node>> getAllBuiltin() async { |
| 206 | return selectList("isPow = ? AND isBuiltin = ?", [0, 1]); |
| 207 | } |
| 208 | |
| 209 | static Future<List<Node>> getAllPowBuiltin() async { |
| 210 | return selectList("isPow = ? AND isBuiltin = ?", [1, 1]); |
| 211 | } |
| 212 | |
| 213 | static Future<List<Node>> getAllForWalletType(WalletType type) async { |
| 214 | return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), 0]); |
| 215 | } |
| 216 | |
| 217 | static Future<Node?> getDefaultForWalletType(WalletType type) async { |
| 218 | return (await selectList( |
| 219 | "typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), 0, 1])) |
| 220 | .firstOrNull; |
| 221 | } |
| 222 | |
| 223 | static Future<Node?> getDefaultPowForWalletType(WalletType type) async { |
| 224 | return (await selectList( |
| 225 | "typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), 1, 1])) |
| 226 | .firstOrNull; |
| 227 | } |
| 228 | |
| 229 | static Future<List<Node>> getAllForWalletTypePow(WalletType type) async { |
| 230 | return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), 1]); |
| 231 | } |
| 232 | |
| 233 | static Future<List<Node>> getAllPow() async { |
| 234 | return selectList("isPow = ?", [1]); |
| 235 | } |
| 236 | |
| 237 | static Future<Node?> get(int id) async { |
| 238 | return select("${selfIdColumn} = ?", [id]); |
| 239 | } |
| 240 | |
| 241 | int id; |
| 242 | late String uriRaw; |
| 243 | String? login; |
| 244 | String? password; |
| 245 | late int typeRaw; |
| 246 | bool? useSSL; |
| 247 | bool trusted; |
| 248 | bool isPow; |
| 249 | String? socksProxyAddress; |
| 250 | String? path; |
| 251 | bool? isElectrs; |
| 252 | bool? supportsSilentPayments; |
| 253 | bool? supportsMweb; |
| 254 | bool isEnabledForAutoSwitching; |
| 255 | bool isOfficial; |
| 256 | bool isBuiltin; |
| 257 | bool isDefault; |
| 258 | |
| 259 | String? label; |
| 260 | |
| 261 | static String get tableName => "Node"; |
| 262 | static String get selfIdColumn => "${tableName}Id"; |
| 263 | |
| 264 | bool get isSSL => useSSL ?? false; |
| 265 | |
| 266 | bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty; |
| 267 | |
| 268 | Uri get uri { |
| 269 | try { |
| 270 | return _uri; |
| 271 | } catch (e) { |
| 272 | printV(e); |
| 273 | return Uri(); |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | Uri get _uri { |
| 278 | switch (type) { |
| 279 | case WalletType.monero: |
| 280 | case WalletType.zcash: |
| 281 | case WalletType.haven: |
| 282 | case WalletType.wownero: |
| 283 | return Uri.http(uriRaw, ''); |
| 284 | case WalletType.bitcoin: |
| 285 | case WalletType.litecoin: |
| 286 | case WalletType.bitcoinCash: |
| 287 | case WalletType.dogecoin: |
| 288 | return createUriFromElectrumAddress(uriRaw, path!); |
| 289 | case WalletType.nano: |
| 290 | case WalletType.banano: |
| 291 | case WalletType.ethereum: |
| 292 | case WalletType.polygon: |
| 293 | case WalletType.base: |
| 294 | case WalletType.bsc: |
| 295 | case WalletType.arbitrum: |
| 296 | case WalletType.solana: |
| 297 | case WalletType.tron: |
| 298 | case WalletType.zano: |
| 299 | case WalletType.decred: |
| 300 | return Uri.parse( |
| 301 | "http${isSSL ? "s" : ""}://$uriRaw${path!.startsWith("/") || path!.isEmpty ? path : "/$path"}"); |
| 302 | case WalletType.none: |
| 303 | throw Exception('Unexpected type ${type.toString()} for Node uri'); |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | bool get isValidProxyAddress => socksProxyAddress?.contains(':') ?? false; |
| 308 | |
| 309 | @override |
| 310 | bool operator ==(other) => |
| 311 | other is Node && |
| 312 | (other.uriRaw == uriRaw && |
| 313 | other.login == login && |
| 314 | other.label == label && |
| 315 | other.password == password && |
| 316 | other.typeRaw == typeRaw && |
| 317 | other.useSSL == useSSL && |
| 318 | other.trusted == trusted && |
| 319 | other.socksProxyAddress == socksProxyAddress && |
| 320 | other.path == path); |
| 321 | |
| 322 | @override |
| 323 | int get hashCode => |
| 324 | uriRaw.hashCode ^ |
| 325 | login.hashCode ^ |
| 326 | label.hashCode ^ |
| 327 | password.hashCode ^ |
| 328 | typeRaw.hashCode ^ |
| 329 | useSSL.hashCode ^ |
| 330 | trusted.hashCode ^ |
| 331 | socksProxyAddress.hashCode ^ |
| 332 | path.hashCode; |
| 333 | |
| 334 | WalletType get type => deserializeFromInt(typeRaw); |
| 335 | |
| 336 | set type(WalletType type) => typeRaw = serializeToInt(type); |
| 337 | |
| 338 | Future<bool> requestNode() async { |
| 339 | try { |
| 340 | switch (type) { |
| 341 | case WalletType.monero: |
| 342 | case WalletType.haven: |
| 343 | case WalletType.wownero: |
| 344 | return requestMoneroNode(); |
| 345 | case WalletType.nano: |
| 346 | case WalletType.banano: |
| 347 | return requestNanoNode(); |
| 348 | case WalletType.bitcoin: |
| 349 | case WalletType.litecoin: |
| 350 | case WalletType.bitcoinCash: |
| 351 | case WalletType.ethereum: |
| 352 | case WalletType.polygon: |
| 353 | case WalletType.base: |
| 354 | case WalletType.arbitrum: |
| 355 | case WalletType.bsc: |
| 356 | case WalletType.solana: |
| 357 | case WalletType.tron: |
| 358 | case WalletType.dogecoin: |
| 359 | case WalletType.zcash: |
| 360 | return requestElectrumServer(); |
| 361 | case WalletType.zano: |
| 362 | return requestZanoNode(); |
| 363 | case WalletType.decred: |
| 364 | return requestDecredNode(); |
| 365 | case WalletType.none: |
| 366 | return false; |
| 367 | } |
| 368 | } catch (_) { |
| 369 | return false; |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | Future<bool> requestZanoNode() async { |
| 374 | final path = '/json_rpc'; |
| 375 | final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path); |
| 376 | final body = {'jsonrpc': '2.0', 'id': '0', 'method': "getinfo"}; |
| 377 | |
| 378 | try { |
| 379 | final jsonBody = json.encode(body); |
| 380 | |
| 381 | final response = await ProxyWrapper().post( |
| 382 | clearnetUri: rpcUri, |
| 383 | headers: {'Content-Type': 'application/json'}, |
| 384 | body: jsonBody, |
| 385 | ); |
| 386 | |
| 387 | final resBody = json.decode(response.body) as Map<String, dynamic>; |
| 388 | |
| 389 | return resBody['result']['height'] != null; |
| 390 | } catch (e) { |
| 391 | printV("error: $e"); |
| 392 | return false; |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | Future<bool> requestMoneroNode({String methodName = 'get_info'}) async { |
| 397 | if (useSocksProxy) { |
| 398 | return await requestNodeWithProxy(); |
| 399 | } |
| 400 | |
| 401 | final path = '/json_rpc'; |
| 402 | final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path); |
| 403 | final body = {'jsonrpc': '2.0', 'id': '0', 'method': methodName}; |
| 404 | |
| 405 | try { |
| 406 | final jsonBody = json.encode(body); |
| 407 | |
| 408 | final response = await ProxyWrapper().post( |
| 409 | clearnetUri: rpcUri, |
| 410 | headers: {'Content-Type': 'application/json'}, |
| 411 | body: jsonBody, |
| 412 | allowMitmMoneroBypassSSLCheck: true); |
| 413 | // Check if we received a 401 Unauthorized response |
| 414 | if (response.statusCode == 401) { |
| 415 | final daemonRpc = DaemonRpc( |
| 416 | rpcUri.toString(), |
| 417 | username: login ?? '', |
| 418 | password: password ?? '', |
| 419 | ); |
| 420 | final response = await daemonRpc.call('get_info', {}); |
| 421 | return !(response['offline'] as bool); |
| 422 | } |
| 423 | |
| 424 | final responseString = await response.body; |
| 425 | |
| 426 | if ((responseString.contains("400 Bad Request") // Some other generic error |
| 427 | || |
| 428 | responseString.contains("plain HTTP request was sent to HTTPS port") // Cloudflare |
| 429 | || |
| 430 | response.headers["location"] != null // Generic reverse proxy |
| 431 | || |
| 432 | responseString |
| 433 | .contains("301 Moved Permanently") // Poorly configured generic reverse proxy |
| 434 | ) && |
| 435 | !(useSSL ?? false)) { |
| 436 | final oldUseSSL = useSSL; |
| 437 | useSSL = true; |
| 438 | try { |
| 439 | final ret = await requestMoneroNode(methodName: methodName); |
| 440 | if (ret == true) { |
| 441 | await save(); |
| 442 | return ret; |
| 443 | } |
| 444 | useSSL = oldUseSSL; |
| 445 | } catch (e) { |
| 446 | useSSL = oldUseSSL; |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | final resBody = json.decode(response.body) as Map<String, dynamic>; |
| 451 | return !(resBody['result']['offline'] as bool); |
| 452 | } catch (e) { |
| 453 | printV("error: $e"); |
| 454 | return false; |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | Future<bool> requestNodeWithProxy() async { |
| 459 | if (!isValidProxyAddress && !CakeTor.instance!.enabled) { |
| 460 | return false; |
| 461 | } |
| 462 | |
| 463 | String? proxy = socksProxyAddress; |
| 464 | |
| 465 | if ((proxy?.isEmpty ?? true) && CakeTor.instance!.enabled) { |
| 466 | proxy = "${InternetAddress.loopbackIPv4.address}:${CakeTor.instance!.port}"; |
| 467 | } |
| 468 | printV("proxy: $proxy"); |
| 469 | if (proxy == null) { |
| 470 | return false; |
| 471 | } |
| 472 | final proxyAddress = proxy.split(':')[0]; |
| 473 | final proxyPort = int.parse(proxy.split(':')[1]); |
| 474 | try { |
| 475 | final socket = await Socket.connect(proxyAddress, proxyPort, timeout: Duration(seconds: 5)); |
| 476 | socket.destroy(); |
| 477 | return true; |
| 478 | } catch (_) { |
| 479 | return false; |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | // TODO: this will return true most of the time, even if the node has useSSL set to true while |
| 484 | // it doesn't support SSL or vice versa, because it will connect normally, but it will fail if |
| 485 | // you try to communicate with it |
| 486 | Future<bool> requestElectrumServer() async { |
| 487 | try { |
| 488 | final ProxySocket socket; |
| 489 | socket = await ProxyWrapper().getSocksSocket(useSSL ?? false, uri.host, uri.port); |
| 490 | |
| 491 | socket.destroy(); |
| 492 | return true; |
| 493 | } catch (_) { |
| 494 | return false; |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | Future<bool> requestNanoNode() async { |
| 499 | try { |
| 500 | final response = await ProxyWrapper().post( |
| 501 | clearnetUri: uri, |
| 502 | headers: {"Content-Type": "application/json", "nano-app": "cake-wallet"}, |
| 503 | body: jsonEncode( |
| 504 | { |
| 505 | "action": "account_balance", |
| 506 | "account": "nano_38713x95zyjsqzx6nm1dsom1jmm668owkeb9913ax6nfgj15az3nu8xkx579", |
| 507 | }, |
| 508 | ), |
| 509 | ); |
| 510 | |
| 511 | final data = jsonDecode(response.body); |
| 512 | if (response.statusCode != 200 || |
| 513 | data["error"] != null || |
| 514 | data["balance"] == null || |
| 515 | data["receivable"] == null) { |
| 516 | throw Exception( |
| 517 | "Error while trying to get balance! ${data["error"] != null ? data["error"] : ""}"); |
| 518 | } |
| 519 | return true; |
| 520 | } catch (_) { |
| 521 | return false; |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | Future<bool> requestEthereumServer() async { |
| 526 | try { |
| 527 | final req = await ProxyWrapper() |
| 528 | .getHttpClient() |
| 529 | .getUrl( |
| 530 | uri, |
| 531 | ) |
| 532 | .timeout(Duration(seconds: 15)); |
| 533 | final response = await req.close(); |
| 534 | |
| 535 | return response.statusCode >= 200 && response.statusCode < 300; |
| 536 | } catch (err) { |
| 537 | printV("Failed to request ethereum server: $err"); |
| 538 | return false; |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | Future<bool> requestDecredNode() async { |
| 543 | if (uri.host == "default-spv-nodes") { |
| 544 | // Just show default port as ok. The wallet will connect to a list of known |
| 545 | // nodes automatically. |
| 546 | return true; |
| 547 | } |
| 548 | try { |
| 549 | final socket = await Socket.connect(uri.host, uri.port, timeout: Duration(seconds: 5)); |
| 550 | socket.destroy(); |
| 551 | return true; |
| 552 | } catch (_) { |
| 553 | return false; |
| 554 | } |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | /// https://github.com/ManyMath/digest_auth/ |
| 559 | /// HTTP Digest authentication. |
| 560 | /// |
| 561 | /// Adapted from https://github.com/dart-lang/http/issues/605#issue-963962341. |
| 562 | /// |
| 563 | /// Created because http_auth was not working for Monero daemon RPC responses. |
| 564 | class DigestAuth { |
| 565 | final String username; |
| 566 | final String password; |
| 567 | String? realm; |
| 568 | String? nonce; |
| 569 | String? uri; |
| 570 | String? qop = "auth"; |
| 571 | int _nonceCount = 0; |
| 572 | |
| 573 | DigestAuth(this.username, this.password); |
| 574 | |
| 575 | /// Initialize Digest parameters from the `WWW-Authenticate` header. |
| 576 | void initFromAuthorizationHeader(String authInfo) { |
| 577 | final Map<String, String>? values = _splitAuthenticateHeader(authInfo); |
| 578 | if (values != null) { |
| 579 | realm = values['realm']; |
| 580 | // Check if the nonce has changed. |
| 581 | if (nonce != values['nonce']) { |
| 582 | nonce = values['nonce']; |
| 583 | _nonceCount = 0; // Reset nonce count when nonce changes. |
| 584 | } |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | /// Generate the Digest Authorization header. |
| 589 | String getAuthString(String method, String uri) { |
| 590 | this.uri = uri; |
| 591 | _nonceCount++; |
| 592 | String cnonce = _computeCnonce(); |
| 593 | String nc = _formatNonceCount(_nonceCount); |
| 594 | |
| 595 | String ha1 = md5Hash("$username:$realm:$password"); |
| 596 | String ha2 = md5Hash("$method:$uri"); |
| 597 | String response = md5Hash("$ha1:$nonce:$nc:$cnonce:$qop:$ha2"); |
| 598 | |
| 599 | return 'Digest username="$username", realm="$realm", nonce="$nonce", uri="$uri", qop=$qop, nc=$nc, cnonce="$cnonce", response="$response"'; |
| 600 | } |
| 601 | |
| 602 | /// Helper to parse the `WWW-Authenticate` header. |
| 603 | Map<String, String>? _splitAuthenticateHeader(String? header) { |
| 604 | if (header == null || !header.startsWith('Digest ')) { |
| 605 | return null; |
| 606 | } |
| 607 | String token = header.substring(7); // Remove 'Digest '. |
| 608 | final Map<String, String> result = {}; |
| 609 | |
| 610 | final components = token.split(',').map((token) => token.trim()); |
| 611 | for (final component in components) { |
| 612 | final kv = component.split('='); |
| 613 | final key = kv[0]; |
| 614 | final value = kv.sublist(1).join('=').replaceAll('"', ''); |
| 615 | result[key] = value; |
| 616 | } |
| 617 | return result; |
| 618 | } |
| 619 | |
| 620 | /// Helper to compute a random cnonce. |
| 621 | String _computeCnonce() { |
| 622 | final math.Random rnd = math.Random(); |
| 623 | final List<int> values = List<int>.generate(16, (i) => rnd.nextInt(256)); |
| 624 | return hex.encode(values); |
| 625 | } |
| 626 | |
| 627 | /// Helper to format the nonce count. |
| 628 | String _formatNonceCount(int count) => count.toRadixString(16).padLeft(8, '0'); |
| 629 | |
| 630 | /// Compute the MD5 hash of a string. |
| 631 | String md5Hash(String input) { |
| 632 | return md5.convert(utf8.encode(input)).toString(); |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | class DaemonRpc { |
| 637 | final String rpcUrl; |
| 638 | final String username; |
| 639 | final String password; |
| 640 | |
| 641 | DaemonRpc(this.rpcUrl, {required this.username, required this.password}); |
| 642 | |
| 643 | /// Perform a JSON-RPC call with Digest Authentication. |
| 644 | Future<Map<String, dynamic>> call(String method, Map<String, dynamic> params) async { |
| 645 | final client = ProxyWrapper().getHttpIOClient(); |
| 646 | final DigestAuth digestAuth = DigestAuth(username, password); |
| 647 | |
| 648 | // Initial request to get the `WWW-Authenticate` header. |
| 649 | final initialResponse = await client.post( |
| 650 | Uri.parse(rpcUrl), |
| 651 | headers: { |
| 652 | 'Content-Type': 'application/json', |
| 653 | }, |
| 654 | body: jsonEncode({ |
| 655 | 'jsonrpc': '2.0', |
| 656 | 'id': '0', |
| 657 | 'method': method, |
| 658 | 'params': params, |
| 659 | }), |
| 660 | ); |
| 661 | |
| 662 | if (initialResponse.statusCode != 401 || |
| 663 | !initialResponse.headers.containsKey('www-authenticate')) { |
| 664 | throw Exception('Unexpected response: ${initialResponse.body}'); |
| 665 | } |
| 666 | |
| 667 | // Extract Digest details from `WWW-Authenticate` header. |
| 668 | final String authInfo = initialResponse.headers['www-authenticate']!; |
| 669 | digestAuth.initFromAuthorizationHeader(authInfo); |
| 670 | |
| 671 | // Create Authorization header for the second request. |
| 672 | String uri = Uri.parse(rpcUrl).path; |
| 673 | String authHeader = digestAuth.getAuthString('POST', uri); |
| 674 | |
| 675 | // Make the authenticated request. |
| 676 | final authenticatedResponse = await client.post( |
| 677 | Uri.parse(rpcUrl), |
| 678 | headers: { |
| 679 | 'Content-Type': 'application/json', |
| 680 | 'Authorization': authHeader, |
| 681 | }, |
| 682 | body: jsonEncode({ |
| 683 | 'jsonrpc': '2.0', |
| 684 | 'id': '0', |
| 685 | 'method': method, |
| 686 | 'params': params, |
| 687 | }), |
| 688 | ); |
| 689 | |
| 690 | if (authenticatedResponse.statusCode != 200) { |
| 691 | throw Exception('RPC call failed: ${authenticatedResponse.body}'); |
| 692 | } |
| 693 | |
| 694 | final Map<String, dynamic> result = |
| 695 | jsonDecode(authenticatedResponse.body) as Map<String, dynamic>; |
| 696 | if (result['error'] != null) { |
| 697 | throw Exception('RPC Error: ${result['error']}'); |
| 698 | } |
| 699 | |
| 700 | return result['result'] as Map<String, dynamic>; |
| 701 | } |
| 702 | } |