243 lines
8.0 KiB
Dart
243 lines
8.0 KiB
Dart
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<RelayTunnel> 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<int>.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 = <String, Object>{
|
|
'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 = <String, Object>{
|
|
'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<String, dynamic> ||
|
|
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<String, dynamic>(
|
|
'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<void>.delayed(const Duration(milliseconds: 250));
|
|
}
|
|
throw const EdgeException();
|
|
}
|
|
|
|
static Future<void> stopTunnel(String id) async {
|
|
try {
|
|
await _tunnelChannel.invokeMethod<void>('stopRelay', {'id': id});
|
|
} on PlatformException {
|
|
// Process shutdown also closes native sockets.
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _post(Uri uri, Map<String, Object> 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<String, dynamic>) 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<int>.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<int> 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<int> 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();
|
|
}
|
|
}
|