Files

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();
}
}