Files

648 lines
19 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as hashes;
import 'package:web_socket_channel/io.dart';
import 'edge_client.dart';
import 'identity_store.dart';
import 'models.dart';
const protocolMajor = 1;
const protocolMinor = 16;
const _maxDimension = 8192;
const _maxPixels = 33177600;
const _maxCompressedBytes = 64 * 1024 * 1024;
enum AgentPhase {
connecting,
switchingToRelay,
authenticating,
openingDesktop,
waitingForFrame,
connected,
disconnected,
failed,
}
enum AgentError {
network,
certificate,
protocol,
authentication,
totp,
permission,
desktop,
frame,
unknown,
}
class AgentState {
const AgentState(this.phase, [this.error]);
final AgentPhase phase;
final AgentError? error;
}
class RemoteFrame {
const RemoteFrame({
required this.sequence,
required this.width,
required this.height,
required this.bgra,
this.textureId,
});
final int sequence;
final int width;
final int height;
final Uint8List bgra;
final int? textureId;
}
class AgentException implements Exception {
const AgentException(this.error);
final AgentError error;
}
abstract interface class RemoteDesktopClient {
Stream<AgentState> get states;
Stream<RemoteFrame> get frames;
Future<void> connect({String? pairingCode, required String totpCode});
void sendPointer(int x, int y);
void sendButton(String button, bool pressed);
void sendKey(int keysym);
void sendText(String value);
void acknowledgeFrame(int sequence);
Future<void> close();
Future<void> dispose();
}
class AgentClient implements RemoteDesktopClient {
AgentClient({
required this.host,
required this.settings,
IdentityStore? identityStore,
}) : _identityStore = identityStore ?? IdentityStore();
final RemoteHost host;
final AppSettings settings;
final IdentityStore _identityStore;
final StreamController<AgentState> _states =
StreamController<AgentState>.broadcast(sync: true);
final StreamController<RemoteFrame> _frames =
StreamController<RemoteFrame>.broadcast(sync: true);
IOWebSocketChannel? _channel;
StreamIterator<dynamic>? _messages;
HttpClient? _httpClient;
_FrameAssembly? _assembly;
bool _closed = false;
bool _certificateRejected = false;
bool _usingRelay = false;
bool _desktopOpen = false;
RelayTunnel? _relayTunnel;
@override
Stream<AgentState> get states => _states.stream;
@override
Stream<RemoteFrame> get frames => _frames.stream;
@override
Future<void> connect({String? pairingCode, required String totpCode}) async {
_closed = false;
_emit(AgentPhase.connecting);
try {
final expectedFingerprint = normalizeFingerprint(host.certificateSha256);
if (!isValidFingerprint(expectedFingerprint)) {
throw const AgentException(AgentError.certificate);
}
final identity = await _identityStore.loadOrCreate();
late Map<String, dynamic> hello;
if (host.connectionMode == ConnectionMode.relay) {
final relayUri = await _openRelay(identity, pairingCode != null);
hello = await _openTransport(
relayUri,
expectedFingerprint,
usingRelay: true,
);
} else {
try {
hello = await _openTransport(
host.uri,
expectedFingerprint,
usingRelay: false,
);
} on AgentException catch (error) {
if (host.connectionMode != ConnectionMode.automatic ||
error.error != AgentError.network) {
rethrow;
}
_emit(AgentPhase.switchingToRelay);
final relayUri = await _openRelay(identity, pairingCode != null);
hello = await _openTransport(
relayUri,
expectedFingerprint,
usingRelay: true,
);
}
}
_emit(AgentPhase.authenticating);
final challenge = _decodeUnpaddedBase64(_string(hello, 'challenge'));
if (challenge.length != 32) {
throw const AgentException(AgentError.protocol);
}
final payload = <int>[
...utf8.encode('remotedesk-agent-auth-v1\u0000'),
...challenge,
];
final signature = await _identityStore.sign(identity, payload);
final publicKey = _encodeUnpaddedBase64(identity.publicKey.bytes);
final command = pairingCode == null
? <String, Object>{
'type': 'authenticate',
'protocol_major': protocolMajor,
'protocol_minor': protocolMinor,
'client_public_key': publicKey,
'signature': _encodeUnpaddedBase64(signature),
}
: <String, Object>{
'type': 'pair',
'protocol_major': protocolMajor,
'protocol_minor': protocolMinor,
'client_name': 'RemoteDesk Android',
'client_public_key': publicKey,
'signature': _encodeUnpaddedBase64(signature),
'code': pairingCode,
};
_send(command);
final totpRequired = await _nextMessage(const Duration(seconds: 8));
if (totpRequired['type'] == 'error') {
throw AgentException(_serverError(_string(totpRequired, 'code')));
}
if (totpRequired['type'] != 'totp_required' ||
_integer(totpRequired, 'digits') != 6 ||
_integer(totpRequired, 'period_seconds') != 30) {
throw const AgentException(AgentError.protocol);
}
_send({'type': 'verify_totp', 'code': totpCode});
final authenticated = await _nextMessage(const Duration(seconds: 8));
_validateAuthentication(authenticated);
_emit(AgentPhase.openingDesktop);
_send({
'type': 'open_desktop',
'user': host.user,
'max_width': settings.maxWidth,
'max_height': settings.maxHeight,
'frames_per_second': settings.framesPerSecond,
'resume_token': null,
'client_fingerprint': null,
'edge_session_id': null,
'webrtc_h264': false,
'opus_audio': false,
'clipboard_read': false,
'clipboard_write': false,
});
final opened = await _nextMessage(const Duration(seconds: 15));
if (opened['type'] == 'error') {
throw const AgentException(AgentError.desktop);
}
if (opened['type'] != 'desktop_opened' ||
opened['encoding'] != 'zlib_bgra') {
throw const AgentException(AgentError.protocol);
}
_desktopOpen = true;
_emit(AgentPhase.waitingForFrame);
unawaited(_readLoop());
} on AgentException catch (error) {
await _fail(error.error);
rethrow;
} on HandshakeException {
final error = _certificateRejected
? AgentError.certificate
: AgentError.network;
await _fail(error);
throw AgentException(error);
} on Object {
final error = _certificateRejected
? AgentError.certificate
: AgentError.network;
await _fail(error);
throw AgentException(error);
}
}
Future<Uri> _openRelay(ClientIdentity identity, bool pairing) async {
final tunnel = await EdgeClient(
identityStore: _identityStore,
).authorize(host: host, identity: identity, pairing: pairing);
_relayTunnel = tunnel;
return Uri(scheme: 'wss', host: '127.0.0.1', port: tunnel.port, path: '/');
}
Future<Map<String, dynamic>> _openTransport(
Uri uri,
String expectedFingerprint, {
required bool usingRelay,
}) async {
_certificateRejected = false;
_usingRelay = usingRelay;
final httpClient = HttpClient(
context: SecurityContext(withTrustedRoots: false),
);
_httpClient = httpClient;
httpClient.connectionTimeout = const Duration(seconds: 10);
httpClient.badCertificateCallback = (certificate, _, _) {
final actual = hashes.sha256.convert(certificate.der).toString();
final accepted = actual == expectedFingerprint;
_certificateRejected = !accepted;
return accepted;
};
try {
final channel = IOWebSocketChannel.connect(
uri,
customClient: httpClient,
connectTimeout: const Duration(seconds: 12),
pingInterval: const Duration(seconds: 20),
);
_channel = channel;
await channel.ready.timeout(const Duration(seconds: 12));
_messages = StreamIterator(channel.stream);
final hello = await _nextMessage(const Duration(seconds: 8));
_validateHello(hello, expectedFingerprint);
return hello;
} on AgentException {
await _closeTransport();
rethrow;
} on HandshakeException {
final error = _certificateRejected
? AgentError.certificate
: AgentError.network;
await _closeTransport();
throw AgentException(error);
} on Object {
final error = _certificateRejected
? AgentError.certificate
: AgentError.network;
await _closeTransport();
throw AgentException(error);
}
}
Future<void> _closeTransport() async {
final channel = _channel;
_channel = null;
_messages = null;
try {
await channel?.sink.close();
} on Object {
// The transport may already be closed.
}
_httpClient?.close(force: true);
_httpClient = null;
}
@override
void sendPointer(int x, int y) {
if (!_desktopOpen) return;
_send({
'type': 'desktop_input',
'event': {
'kind': 'pointer_move',
'x': x.clamp(0, 65535),
'y': y.clamp(0, 65535),
},
});
}
@override
void sendButton(String button, bool pressed) {
if (!_desktopOpen) return;
_send({
'type': 'desktop_input',
'event': {'kind': 'pointer_button', 'button': button, 'pressed': pressed},
});
}
@override
void sendKey(int keysym) {
if (!_desktopOpen) return;
for (final state in const ['pressed', 'released']) {
_send({
'type': 'desktop_input',
'event': {'kind': 'key', 'keysym': keysym, 'state': state},
});
}
}
@override
void sendText(String value) {
for (final rune in value.runes) {
sendKey(rune <= 0xff ? rune : 0x01000000 | rune);
}
}
@override
void acknowledgeFrame(int sequence) {
if (!_desktopOpen) return;
_send({'type': 'desktop_frame_ack', 'sequence': sequence});
}
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
_desktopOpen = false;
final channel = _channel;
if (channel != null) {
try {
_send({'type': 'close'});
} on Object {
// The connection may already be closed.
}
try {
await channel.sink.close();
} on Object {
// Nothing else is required during shutdown.
}
}
_httpClient?.close(force: true);
final relayTunnel = _relayTunnel;
_relayTunnel = null;
if (relayTunnel != null) await EdgeClient.stopTunnel(relayTunnel.id);
_emit(AgentPhase.disconnected);
}
@override
Future<void> dispose() async {
await close();
await _states.close();
await _frames.close();
}
Future<void> _readLoop() async {
try {
while (!_closed) {
final event = await _nextMessage(const Duration(seconds: 45));
switch (event['type']) {
case 'desktop_frame_start':
_startFrame(event);
case 'desktop_frame_chunk':
_addFrameChunk(event);
case 'desktop_frame_complete':
await _completeFrame(event);
case 'desktop_closed':
await _fail(AgentError.desktop);
return;
case 'error':
await _fail(_serverError(_string(event, 'code')));
return;
case 'desktop_pong':
break;
default:
break;
}
}
} on AgentException catch (error) {
await _fail(error.error);
} on Object {
if (!_closed) await _fail(AgentError.network);
}
}
void _startFrame(Map<String, dynamic> event) {
if (_assembly != null) {
throw const AgentException(AgentError.frame);
}
final metadata = event['metadata'];
if (metadata is! Map<String, dynamic>) {
throw const AgentException(AgentError.frame);
}
final sequence = _integer(metadata, 'sequence');
final width = _integer(metadata, 'width');
final height = _integer(metadata, 'height');
final uncompressed = _integer(metadata, 'uncompressed_bytes');
final compressed = _integer(metadata, 'compressed_bytes');
final chunks = _integer(metadata, 'chunk_count');
final pixels = width * height;
if (metadata['encoding'] != 'zlib_bgra' ||
width <= 0 ||
height <= 0 ||
width > _maxDimension ||
height > _maxDimension ||
pixels > _maxPixels ||
uncompressed != pixels * 4 ||
compressed <= 0 ||
compressed > _maxCompressedBytes ||
chunks <= 0 ||
chunks > 65535) {
throw const AgentException(AgentError.frame);
}
_assembly = _FrameAssembly(
sequence: sequence,
width: width,
height: height,
uncompressedBytes: uncompressed,
compressedBytes: compressed,
chunkCount: chunks,
);
}
void _addFrameChunk(Map<String, dynamic> event) {
final assembly = _assembly;
if (assembly == null ||
_integer(event, 'sequence') != assembly.sequence ||
_integer(event, 'index') != assembly.nextChunk) {
throw const AgentException(AgentError.frame);
}
final bytes = _decodeUnpaddedBase64(_string(event, 'data'));
if (bytes.isEmpty ||
assembly.length + bytes.length > assembly.compressedBytes) {
throw const AgentException(AgentError.frame);
}
assembly.add(bytes);
}
Future<void> _completeFrame(Map<String, dynamic> event) async {
final assembly = _assembly;
_assembly = null;
if (assembly == null ||
_integer(event, 'sequence') != assembly.sequence ||
assembly.nextChunk != assembly.chunkCount ||
assembly.length != assembly.compressedBytes) {
throw const AgentException(AgentError.frame);
}
final compressed = assembly.takeBytes();
final actualHash = hashes.sha256.convert(compressed).toString();
if (actualHash != _string(event, 'sha256').toLowerCase()) {
throw const AgentException(AgentError.frame);
}
final decompressed = await Isolate.run(
() => Uint8List.fromList(ZLibCodec().decode(compressed)),
);
if (decompressed.length != assembly.uncompressedBytes) {
throw const AgentException(AgentError.frame);
}
if (_closed) return;
_emit(AgentPhase.connected);
_frames.add(
RemoteFrame(
sequence: assembly.sequence,
width: assembly.width,
height: assembly.height,
bgra: decompressed,
),
);
}
Future<Map<String, dynamic>> _nextMessage(Duration timeout) async {
final messages = _messages;
if (messages == null || !await messages.moveNext().timeout(timeout)) {
throw const AgentException(AgentError.network);
}
final current = messages.current;
if (current is! String) {
throw const AgentException(AgentError.protocol);
}
final decoded = jsonDecode(current);
if (decoded is! Map<String, dynamic>) {
throw const AgentException(AgentError.protocol);
}
return decoded;
}
void _validateHello(Map<String, dynamic> hello, String fingerprint) {
final relayIdentityMatches =
!_usingRelay ||
_string(hello, 'device_public_key') == host.agentPublicKey;
if (hello['type'] != 'hello' ||
_integer(hello, 'protocol_major') != protocolMajor ||
_integer(hello, 'protocol_minor') < 16 ||
hello['desktop'] != true ||
_string(hello, 'tls_certificate_sha256').toLowerCase() != fingerprint ||
!relayIdentityMatches) {
throw const AgentException(AgentError.protocol);
}
}
void _validateAuthentication(Map<String, dynamic> response) {
if (response['type'] == 'error') {
throw AgentException(_serverError(_string(response, 'code')));
}
if (response['type'] != 'authenticated' && response['type'] != 'paired') {
throw const AgentException(AgentError.authentication);
}
final permissions = response['permissions'];
if (permissions is! List || !permissions.contains('desktop')) {
throw const AgentException(AgentError.permission);
}
}
void _send(Map<String, Object?> value) {
final channel = _channel;
if (channel == null) throw const AgentException(AgentError.network);
channel.sink.add(jsonEncode(value));
}
void _emit(AgentPhase phase, [AgentError? error]) {
if (!_states.isClosed) _states.add(AgentState(phase, error));
}
Future<void> _fail(AgentError error) async {
_desktopOpen = false;
_assembly = null;
_emit(AgentPhase.failed, error);
_closed = true;
try {
await _channel?.sink.close();
} on Object {
// The transport is already unusable.
}
_httpClient?.close(force: true);
final relayTunnel = _relayTunnel;
_relayTunnel = null;
if (relayTunnel != null) await EdgeClient.stopTunnel(relayTunnel.id);
}
AgentError _serverError(String code) {
final value = code.toLowerCase();
if (value.contains('pair') || value.contains('auth')) {
return AgentError.authentication;
}
if (value.contains('totp')) return AgentError.totp;
if (value.contains('permission') || value.contains('denied')) {
return AgentError.permission;
}
if (value.contains('desktop') || value.contains('session')) {
return AgentError.desktop;
}
return AgentError.unknown;
}
}
class _FrameAssembly {
_FrameAssembly({
required this.sequence,
required this.width,
required this.height,
required this.uncompressedBytes,
required this.compressedBytes,
required this.chunkCount,
});
final int sequence;
final int width;
final int height;
final int uncompressedBytes;
final int compressedBytes;
final int chunkCount;
final BytesBuilder _bytes = BytesBuilder(copy: false);
int nextChunk = 0;
int get length => _bytes.length;
void add(Uint8List bytes) {
_bytes.add(bytes);
nextChunk += 1;
}
Uint8List takeBytes() => _bytes.takeBytes();
}
int _integer(Map<String, dynamic> value, String key) {
final result = value[key];
if (result is! int) throw const AgentException(AgentError.protocol);
return result;
}
String _string(Map<String, dynamic> value, String key) {
final result = value[key];
if (result is! String) throw const AgentException(AgentError.protocol);
return result;
}
String _encodeUnpaddedBase64(List<int> bytes) =>
base64Encode(bytes).replaceAll('=', '');
Uint8List _decodeUnpaddedBase64(String value) {
if (!RegExp(r'^[A-Za-z0-9+/]*$').hasMatch(value) || value.length % 4 == 1) {
throw const AgentException(AgentError.protocol);
}
final padding = '=' * ((4 - value.length % 4) % 4);
try {
return Uint8List.fromList(base64Decode('$value$padding'));
} on FormatException {
throw const AgentException(AgentError.protocol);
}
}