import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart' as hashes; import 'package:flutter/services.dart'; import 'identity_store.dart'; import 'models.dart'; const _intentDomain = 'RemoteDesk/EdgeSessionIntent/v1\u0000'; const _deviceIdDomain = 'remotedesk-edge-device-id-v1\u0000'; const _maxResponseBytes = 64 * 1024; class RelayTunnel { const RelayTunnel({required this.id, required this.port}); final String id; final int port; } class EdgeException implements Exception { const EdgeException(); } class EdgeClient { EdgeClient({required this.identityStore}); static const _tunnelChannel = MethodChannel('com.remotedesk/tunnel'); final IdentityStore identityStore; final Random _random = Random.secure(); Future authorize({ required RemoteHost host, required ClientIdentity identity, required bool pairing, }) async { final api = normalizeEdgeApiUri(host.edgeApiUrl ?? ''); final agentPublicKey = host.agentPublicKey ?? ''; final agentKeyBytes = _decodeUnpadded(agentPublicKey); if (agentKeyBytes.length != 32 || _encodeUnpadded(agentKeyBytes) != agentPublicKey) { throw const EdgeException(); } final deviceId = hashes.sha256.convert([ ...utf8.encode(_deviceIdDomain), ...agentKeyBytes, ]).toString(); final publicKey = _encodeUnpadded(identity.publicKey.bytes); final requestId = _identifier('request'); final sessionId = _identifier('session'); final sessionType = pairing ? 'pairing' : 'desktop'; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final expires = now + 60; final nonce = Uint8List.fromList( List.generate(32, (_) => _random.nextInt(256)), ); final payload = BytesBuilder(copy: false)..add(utf8.encode(_intentDomain)); _appendField(payload, 1, utf8.encode(deviceId)); _appendField(payload, 2, utf8.encode(requestId)); _appendField(payload, 3, utf8.encode(sessionId)); _appendField(payload, 4, utf8.encode(sessionType)); _appendField(payload, 5, utf8.encode(host.user)); _appendField(payload, 6, utf8.encode(sessionType)); _appendField(payload, 7, utf8.encode(publicKey)); _appendField(payload, 8, nonce); _appendField(payload, 9, _uint64(now)); _appendField(payload, 10, _uint64(expires)); final signature = _encodeUnpadded( await identityStore.sign(identity, payload.takeBytes()), ); final intent = { 'device_id': deviceId, 'request_id': requestId, 'session_id': sessionId, 'session_type': sessionType, 'target_user': host.user, 'requested_permissions': [sessionType], 'client_public_key': publicKey, 'nonce': _encodeUnpadded(nonce), 'issued_unix': now, 'expires_unix': expires, 'signature': signature, }; final submitted = await _post(api.resolve('v1/signals/requests'), intent); if (submitted['request_id'] != requestId) throw const EdgeException(); final statusRequest = { 'request_id': requestId, 'client_public_key': publicKey, 'signature': signature, }; final deadline = DateTime.now().add(const Duration(seconds: 18)); while (DateTime.now().isBefore(deadline)) { final status = await _post( api.resolve('v1/signals/status'), statusRequest, ); if (status['request_id'] != requestId || status['device_id'] != deviceId || status['session_id'] != sessionId || status['expires_unix'] != expires) { throw const EdgeException(); } if (status['state'] == 'completed') { final expected = pairing ? 'pairing_window_open' : 'authorized'; if (status['accepted'] != true || status['result_code'] != expected) { throw const EdgeException(); } final relay = status['relay']; if (relay is! Map || relay['expires_unix'] != expires || relay['relay_address'] is! String || relay['ticket'] is! String || relay['max_bytes'] is! int || (relay['max_bytes'] as int) <= 0) { throw const EdgeException(); } final result = await _tunnelChannel.invokeMapMethod( 'startRelay', {'relayAddress': relay['relay_address'], 'ticket': relay['ticket']}, ); final id = result?['id']; final port = result?['port']; if (id is! String || port is! int || port <= 0 || port > 65535) { throw const EdgeException(); } return RelayTunnel(id: id, port: port); } if (status['state'] != 'pending' && status['state'] != 'delivered') { throw const EdgeException(); } await Future.delayed(const Duration(milliseconds: 250)); } throw const EdgeException(); } static Future stopTunnel(String id) async { try { await _tunnelChannel.invokeMethod('stopRelay', {'id': id}); } on PlatformException { // Process shutdown also closes native sockets. } } Future> _post(Uri uri, Map body) async { final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); try { final request = await client .postUrl(uri) .timeout(const Duration(seconds: 8)); request.followRedirects = false; request.headers.contentType = ContentType.json; request.headers.set( HttpHeaders.userAgentHeader, 'RemoteDesk-Android/1.0', ); request.write(jsonEncode(body)); final response = await request.close().timeout( const Duration(seconds: 10), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw const EdgeException(); } final bytes = BytesBuilder(copy: false); await for (final chunk in response) { if (bytes.length + chunk.length > _maxResponseBytes) { throw const EdgeException(); } bytes.add(chunk); } final decoded = jsonDecode(utf8.decode(bytes.takeBytes())); if (decoded is! Map) throw const EdgeException(); return decoded; } on EdgeException { rethrow; } on Object { throw const EdgeException(); } finally { client.close(force: true); } } String _identifier(String prefix) { final bytes = List.generate(18, (_) => _random.nextInt(256)); return '$prefix-${base64UrlEncode(bytes).replaceAll('=', '')}'; } } Uri normalizeEdgeApiUri(String input) { if (input.length > 2048) throw const FormatException('Edge URL is too long'); final uri = Uri.parse(input.trim()); final loopbackHttp = uri.scheme == 'http' && (uri.host == 'localhost' || InternetAddress.tryParse(uri.host)?.isLoopback == true); if ((uri.scheme != 'https' && !loopbackHttp) || uri.host.isEmpty || uri.userInfo.isNotEmpty || (uri.path.isNotEmpty && uri.path != '/') || uri.hasQuery || uri.hasFragment || uri.port == 0) { throw const FormatException('Edge URL must be an HTTPS origin'); } return Uri( scheme: uri.scheme, host: uri.host, port: uri.hasPort ? uri.port : null, path: '/', ); } void _appendField(BytesBuilder output, int tag, List value) { output.addByte(tag); final length = ByteData(4)..setUint32(0, value.length, Endian.big); output.add(length.buffer.asUint8List()); output.add(value); } Uint8List _uint64(int value) { final data = ByteData(8)..setUint64(0, value, Endian.big); return data.buffer.asUint8List(); } String _encodeUnpadded(List bytes) => base64Encode(bytes).replaceAll('=', ''); Uint8List _decodeUnpadded(String value) { if (!RegExp(r'^[A-Za-z0-9+/]*$').hasMatch(value) || value.length % 4 == 1) { throw const EdgeException(); } try { return Uint8List.fromList( base64Decode('$value${'=' * ((4 - value.length % 4) % 4)}'), ); } on FormatException { throw const EdgeException(); } }