997 lines
34 KiB
Dart
997 lines
34 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:web_socket_channel/io.dart';
|
|
import '../../utils/future_extensions.dart';
|
|
|
|
import '../../models/companion_remote/remote_command.dart';
|
|
import '../../models/companion_remote/remote_session.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import '../base_peer_service.dart';
|
|
import 'remote_auth_context.dart';
|
|
import 'remote_auth_service.dart';
|
|
|
|
// Re-export so callers that import from here get the types.
|
|
export '../base_peer_service.dart' show PeerError, PeerErrorType;
|
|
|
|
/// Backward-compatible aliases so existing callers that reference the
|
|
/// Remote-specific names keep compiling.
|
|
typedef RemotePeerErrorType = PeerErrorType;
|
|
typedef RemotePeerError = PeerError;
|
|
|
|
class CompanionRemotePeerService with KeepaliveMixin {
|
|
// Server-side (host) fields
|
|
HttpServer? _server;
|
|
WebSocket? _clientSocket;
|
|
|
|
// Client-side (remote) fields
|
|
IOWebSocketChannel? _channel;
|
|
|
|
String? _myPeerId;
|
|
String? _hostAddress; // Format: "ip:port"
|
|
RemoteSessionRole? _role;
|
|
String? _selectedAuthContextId;
|
|
String? _selectedHostClientId;
|
|
|
|
// Encrypted channel state
|
|
List<int>? _sessionEncKey;
|
|
int _sendCounter = 0;
|
|
int _recvCounter = 0;
|
|
bool _isAuthenticated = false;
|
|
|
|
final _commandReceivedController = StreamController<RemoteCommand>.broadcast();
|
|
final _deviceConnectedController = StreamController<RemoteDevice>.broadcast();
|
|
final _deviceDisconnectedController = StreamController<void>.broadcast();
|
|
final _errorController = StreamController<RemotePeerError>.broadcast();
|
|
final _connectionStateController = StreamController<RemoteSessionStatus>.broadcast();
|
|
|
|
// Keepalive (via KeepaliveMixin)
|
|
@override
|
|
Duration get pingInterval => const Duration(seconds: 5);
|
|
@override
|
|
Duration get pongTimeout => Duration.zero; // No pong timeout; host just replies inline
|
|
|
|
// Auth rate limiting (per source IP)
|
|
final Map<String, int> _failedAuthAttempts = {};
|
|
final Map<String, DateTime> _authLockouts = {};
|
|
static const int _maxFailedAuthAttempts = 5;
|
|
static const Duration _authLockoutDuration = Duration(seconds: 30);
|
|
|
|
Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream;
|
|
Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream;
|
|
Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream;
|
|
Stream<RemotePeerError> get onError => _errorController.stream;
|
|
Stream<RemoteSessionStatus> get onConnectionStateChanged => _connectionStateController.stream;
|
|
|
|
String? get myPeerId => _myPeerId;
|
|
String? get hostAddress => _hostAddress;
|
|
RemoteSessionRole? get role => _role;
|
|
String? get selectedAuthContextId => _selectedAuthContextId;
|
|
String? get selectedHostClientId => _selectedHostClientId;
|
|
bool get isHost => _role == RemoteSessionRole.host;
|
|
bool get isConnected => _clientSocket != null || (_channel != null && _channel?.closeCode == null);
|
|
|
|
Future<List<String>> _getAllLocalIpAddresses() async {
|
|
try {
|
|
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
|
|
|
final preferred = <String>[];
|
|
final others = <String>[];
|
|
|
|
for (final interface in interfaces) {
|
|
if (interface.name.toLowerCase().contains('lo')) continue;
|
|
|
|
for (final addr in interface.addresses) {
|
|
if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) {
|
|
final name = interface.name.toLowerCase();
|
|
if (name.contains('en') || name.contains('wl') || name.contains('eth')) {
|
|
preferred.add(addr.address);
|
|
} else {
|
|
others.add(addr.address);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final all = [...preferred, ...others];
|
|
if (all.isEmpty) {
|
|
throw const RemotePeerError(type: RemotePeerErrorType.networkError, message: 'No network interface found');
|
|
}
|
|
return all;
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Failed to get local IPs', error: e);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Create a host session — starts WebSocket server, returns local addresses and port.
|
|
Future<({List<String> addresses, int port})> createSession(
|
|
String deviceName,
|
|
String platform,
|
|
List<int> homeSecret,
|
|
String clientIdentifier,
|
|
List<String> homeUserUUIDs,
|
|
) async {
|
|
final auth = RemoteAuthService.instance;
|
|
return createSessionForContexts(deviceName, platform, [
|
|
RemoteAuthContext(
|
|
id: auth.computeAuthContextId(homeSecret),
|
|
backend: 'legacy',
|
|
connectionId: '',
|
|
homeSecret: homeSecret,
|
|
discoveryKey: const [],
|
|
clientIdentifier: clientIdentifier,
|
|
userUuid: homeUserUUIDs.isEmpty ? '' : homeUserUUIDs.first,
|
|
allowedUserUuids: homeUserUUIDs,
|
|
),
|
|
]);
|
|
}
|
|
|
|
/// Create a host session that accepts any of the provided remote identities.
|
|
Future<({List<String> addresses, int port})> createSessionForContexts(
|
|
String deviceName,
|
|
String platform,
|
|
List<RemoteAuthContext> authContexts,
|
|
) async {
|
|
if (authContexts.isEmpty) {
|
|
throw const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No auth contexts available');
|
|
}
|
|
|
|
if (_server != null) {
|
|
await disconnect();
|
|
}
|
|
|
|
_role = RemoteSessionRole.host;
|
|
_myPeerId = 'host';
|
|
|
|
try {
|
|
const int preferredPort = 48632;
|
|
|
|
try {
|
|
_server = await HttpServer.bind(InternetAddress.anyIPv4, preferredPort);
|
|
appLogger.d('CompanionRemote: Server bound to port $preferredPort');
|
|
} catch (e) {
|
|
appLogger.w('CompanionRemote: Port $preferredPort occupied, using random port');
|
|
_server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
|
|
}
|
|
|
|
final localIps = await _getAllLocalIpAddresses();
|
|
final port = _server!.port;
|
|
final addresses = localIps.map((ip) => '$ip:$port').toList();
|
|
_hostAddress = addresses.first;
|
|
|
|
appLogger.d('CompanionRemote: Host server started, addresses: $addresses');
|
|
|
|
_server!.listen((HttpRequest request) async {
|
|
if (request.uri.path == '/ws') {
|
|
try {
|
|
final socket = await WebSocketTransformer.upgrade(request);
|
|
final sourceIp = request.connectionInfo?.remoteAddress.address ?? 'unknown';
|
|
_handleNewWebSocketConnection(socket, deviceName, platform, authContexts, sourceIp);
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Failed to upgrade WebSocket', error: e);
|
|
}
|
|
} else {
|
|
request.response.statusCode = HttpStatus.notFound;
|
|
unawaited(request.response.close());
|
|
}
|
|
});
|
|
|
|
_connectionStateController.add(RemoteSessionStatus.connected);
|
|
|
|
return (addresses: addresses, port: port);
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Failed to create server', error: e);
|
|
_errorController.add(
|
|
RemotePeerError(
|
|
type: RemotePeerErrorType.serverError,
|
|
message: 'Failed to create server: $e',
|
|
originalError: e,
|
|
),
|
|
);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void _handleNewWebSocketConnection(
|
|
WebSocket socket,
|
|
String hostDeviceName,
|
|
String hostPlatform,
|
|
List<RemoteAuthContext> authContexts,
|
|
String sourceIp,
|
|
) {
|
|
appLogger.d('CompanionRemote: New WebSocket connection from $sourceIp');
|
|
|
|
bool isAuthenticated = false;
|
|
Timer? authTimeout;
|
|
final auth = RemoteAuthService.instance;
|
|
final hostNonce = auth.generateNonce();
|
|
|
|
// Check rate limiting
|
|
final lockout = _authLockouts[sourceIp];
|
|
if (lockout != null && DateTime.now().isBefore(lockout)) {
|
|
appLogger.w('CompanionRemote: Connection from $sourceIp rejected (rate limited)');
|
|
socket.close(4005, 'Rate limited');
|
|
return;
|
|
}
|
|
|
|
final primaryContext = authContexts.first;
|
|
|
|
// Send challenge: legacy hostClientId plus all selectable auth contexts.
|
|
socket.add(
|
|
jsonEncode({
|
|
'type': 'challenge',
|
|
'nonce': base64Encode(hostNonce),
|
|
'hostClientId': primaryContext.clientIdentifier,
|
|
'authContexts': [
|
|
for (final context in authContexts) {'id': context.id, 'hostClientId': context.clientIdentifier},
|
|
],
|
|
}),
|
|
);
|
|
|
|
// Authentication timeout
|
|
authTimeout = Timer(const Duration(seconds: 10), () {
|
|
if (!isAuthenticated) {
|
|
appLogger.w('CompanionRemote: Authentication timeout');
|
|
socket.close(4001, 'Authentication timeout');
|
|
}
|
|
});
|
|
|
|
socket.listen(
|
|
(data) async {
|
|
try {
|
|
if (!isAuthenticated) {
|
|
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
|
|
|
if (json['type'] == 'auth') {
|
|
final authTag = json['authTag'] as String?;
|
|
final clientNonceB64 = json['clientNonce'] as String?;
|
|
final userUUID = json['userUUID'] as String?;
|
|
final clientIdentifier = json['clientIdentifier'] as String?;
|
|
final deviceName = json['deviceName'] as String?;
|
|
final platform = json['platform'] as String?;
|
|
final authContextId = json['authContextId'] as String?;
|
|
|
|
if (authTag == null ||
|
|
clientNonceB64 == null ||
|
|
userUUID == null ||
|
|
clientIdentifier == null ||
|
|
deviceName == null ||
|
|
platform == null) {
|
|
socket.add(jsonEncode({'type': 'authFailed'}));
|
|
unawaited(socket.close(4003, 'Authentication failed'));
|
|
return;
|
|
}
|
|
|
|
final clientNonce = base64Decode(clientNonceB64);
|
|
RemoteAuthContext? selectedContext;
|
|
if (authContextId != null && authContextId.isNotEmpty) {
|
|
for (final context in authContexts) {
|
|
if (context.id == authContextId) {
|
|
selectedContext = context;
|
|
break;
|
|
}
|
|
}
|
|
} else if (authContexts.length == 1) {
|
|
selectedContext = primaryContext;
|
|
}
|
|
|
|
if (selectedContext == null) {
|
|
_recordFailedAuth(sourceIp);
|
|
appLogger.w('CompanionRemote: Auth failed — unknown auth context');
|
|
socket.add(jsonEncode({'type': 'authFailed'}));
|
|
unawaited(socket.close(4003, 'Authentication failed'));
|
|
return;
|
|
}
|
|
|
|
// Verify userUUID is allowed for the selected profile connection.
|
|
if (selectedContext.allowedUserUuids.isNotEmpty && !selectedContext.allowedUserUuids.contains(userUUID)) {
|
|
_recordFailedAuth(sourceIp);
|
|
appLogger.w('CompanionRemote: Auth failed — unknown user');
|
|
socket.add(jsonEncode({'type': 'authFailed'}));
|
|
unawaited(socket.close(4003, 'Authentication failed'));
|
|
return;
|
|
}
|
|
|
|
// Verify auth tag
|
|
final valid = auth.verifyAuthTag(
|
|
authTag: authTag,
|
|
homeSecret: selectedContext.homeSecret,
|
|
hostNonce: hostNonce,
|
|
clientNonce: clientNonce,
|
|
hostClientId: selectedContext.clientIdentifier,
|
|
userUUID: userUUID,
|
|
clientIdentifier: clientIdentifier,
|
|
deviceName: deviceName,
|
|
platform: platform,
|
|
);
|
|
|
|
if (!valid) {
|
|
_recordFailedAuth(sourceIp);
|
|
appLogger.w('CompanionRemote: Auth failed — invalid auth tag');
|
|
socket.add(jsonEncode({'type': 'authFailed'}));
|
|
unawaited(socket.close(4003, 'Authentication failed'));
|
|
return;
|
|
}
|
|
|
|
// Auth success — derive per-session encryption key
|
|
_failedAuthAttempts.remove(sourceIp);
|
|
isAuthenticated = true;
|
|
authTimeout?.cancel();
|
|
|
|
final sessionEncKey = await auth.deriveSessionEncKey(selectedContext.homeSecret, hostNonce, clientNonce);
|
|
|
|
// Close existing client if present
|
|
if (_clientSocket != null) {
|
|
appLogger.d('CompanionRemote: Replacing existing client connection');
|
|
unawaited(_clientSocket!.close(4004, 'Replaced by new connection'));
|
|
}
|
|
|
|
_clientSocket = socket;
|
|
_sessionEncKey = sessionEncKey;
|
|
_sendCounter = 0;
|
|
_recvCounter = 0;
|
|
_isAuthenticated = true;
|
|
_selectedAuthContextId = selectedContext.id;
|
|
_selectedHostClientId = selectedContext.clientIdentifier;
|
|
|
|
appLogger.d('CompanionRemote: Client authenticated: $deviceName ($platform)');
|
|
|
|
// Send encrypted authSuccess
|
|
await _sendEncryptedToSocket(socket, jsonEncode({'type': 'authSuccess'}));
|
|
|
|
// Notify connection
|
|
final device = RemoteDevice(id: 'remote-client', name: deviceName, platform: platform);
|
|
_deviceConnectedController.add(device);
|
|
_connectionStateController.add(RemoteSessionStatus.connected);
|
|
|
|
// Send device info
|
|
sendDeviceInfo(hostDeviceName, hostPlatform);
|
|
} else {
|
|
appLogger.w('CompanionRemote: Expected auth, got ${json['type']}');
|
|
unawaited(socket.close(4002, 'Authentication required'));
|
|
}
|
|
} else {
|
|
// Encrypted command — data is binary
|
|
final decrypted = await _decryptIncoming(data);
|
|
if (decrypted == null) return;
|
|
|
|
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
|
final command = RemoteCommand.fromJson(json);
|
|
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
|
|
|
if (_shouldSendAck(command)) {
|
|
_sendAck(command);
|
|
}
|
|
|
|
_commandReceivedController.add(command);
|
|
|
|
if (command.type == RemoteCommandType.ping) {
|
|
_sendPong();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Failed to process message', error: e);
|
|
}
|
|
},
|
|
onDone: () {
|
|
authTimeout?.cancel();
|
|
appLogger.d('CompanionRemote: WebSocket connection closed');
|
|
if (isAuthenticated) {
|
|
_clientSocket = null;
|
|
_sessionEncKey = null;
|
|
_isAuthenticated = false;
|
|
_selectedAuthContextId = null;
|
|
_selectedHostClientId = null;
|
|
_deviceDisconnectedController.add(null);
|
|
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
|
stopKeepalive();
|
|
}
|
|
},
|
|
onError: (error) {
|
|
authTimeout?.cancel();
|
|
appLogger.e('CompanionRemote: WebSocket error', error: error);
|
|
_errorController.add(
|
|
RemotePeerError(
|
|
type: RemotePeerErrorType.dataChannelError,
|
|
message: 'WebSocket error: $error',
|
|
originalError: error,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _recordFailedAuth(String sourceIp) {
|
|
final attempts = (_failedAuthAttempts[sourceIp] ?? 0) + 1;
|
|
_failedAuthAttempts[sourceIp] = attempts;
|
|
if (attempts >= _maxFailedAuthAttempts) {
|
|
_authLockouts[sourceIp] = DateTime.now().add(_authLockoutDuration);
|
|
appLogger.w('CompanionRemote: IP $sourceIp locked out for ${_authLockoutDuration.inSeconds}s');
|
|
}
|
|
}
|
|
|
|
/// Join a host session as a remote client.
|
|
Future<void> joinSession(
|
|
String deviceName,
|
|
String platform,
|
|
String hostAddress,
|
|
List<int> homeSecret,
|
|
String hostClientId,
|
|
String userUUID,
|
|
String clientIdentifier,
|
|
) async {
|
|
final auth = RemoteAuthService.instance;
|
|
final context = RemoteAuthContext(
|
|
id: auth.computeAuthContextId(homeSecret),
|
|
backend: 'legacy',
|
|
connectionId: '',
|
|
homeSecret: homeSecret,
|
|
discoveryKey: const [],
|
|
clientIdentifier: clientIdentifier,
|
|
userUuid: userUUID,
|
|
allowedUserUuids: [userUUID],
|
|
);
|
|
return joinSessionWithContexts(
|
|
deviceName,
|
|
platform,
|
|
hostAddress,
|
|
[context],
|
|
authContextId: context.id,
|
|
expectedHostClientId: hostClientId,
|
|
);
|
|
}
|
|
|
|
/// Join a host session with any local auth context that the host also supports.
|
|
Future<void> joinSessionWithContexts(
|
|
String deviceName,
|
|
String platform,
|
|
String hostAddress,
|
|
List<RemoteAuthContext> authContexts, {
|
|
String? authContextId,
|
|
String expectedHostClientId = '',
|
|
}) async {
|
|
if (authContexts.isEmpty) {
|
|
throw const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No auth contexts available');
|
|
}
|
|
|
|
if (_channel != null) {
|
|
await disconnect();
|
|
}
|
|
|
|
_role = RemoteSessionRole.remote;
|
|
_hostAddress = hostAddress;
|
|
_myPeerId = 'remote-${Random.secure().nextInt(99999)}';
|
|
|
|
final completer = Completer<void>();
|
|
final auth = RemoteAuthService.instance;
|
|
|
|
try {
|
|
final url = 'ws://$hostAddress/ws';
|
|
appLogger.d('CompanionRemote: Connecting to $url');
|
|
|
|
_connectionStateController.add(RemoteSessionStatus.connecting);
|
|
|
|
_channel = IOWebSocketChannel.connect(Uri.parse(url));
|
|
await _channel!.ready;
|
|
|
|
List<int>? hostNonce;
|
|
List<int>? clientNonce;
|
|
String? receivedHostClientId;
|
|
|
|
_channel!.stream.listen(
|
|
(data) async {
|
|
try {
|
|
if (_isAuthenticated) {
|
|
// Post-auth: all messages are encrypted binary
|
|
final decrypted = await _decryptIncoming(data);
|
|
if (decrypted == null) return;
|
|
|
|
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
|
final command = RemoteCommand.fromJson(json);
|
|
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
|
|
|
if (_shouldSendAck(command)) {
|
|
_sendAck(command);
|
|
}
|
|
|
|
_commandReceivedController.add(command);
|
|
|
|
if (command.type == RemoteCommandType.ping) {
|
|
_sendPong();
|
|
}
|
|
} else if (_sessionEncKey != null) {
|
|
// Keys derived, waiting for encrypted authSuccess
|
|
final decrypted = await _decryptIncoming(data);
|
|
if (decrypted == null) return;
|
|
|
|
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
|
if (json['type'] == 'authSuccess') {
|
|
_isAuthenticated = true;
|
|
appLogger.d('CompanionRemote: Authentication successful');
|
|
|
|
if (!completer.isCompleted) {
|
|
completer.complete();
|
|
}
|
|
|
|
final device = RemoteDevice(id: 'host', name: 'Desktop', platform: 'desktop');
|
|
_deviceConnectedController.add(device);
|
|
_connectionStateController.add(RemoteSessionStatus.connected);
|
|
|
|
sendDeviceInfo(deviceName, platform);
|
|
startKeepalive();
|
|
} else if (json['type'] == 'authFailed') {
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(
|
|
const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'Authentication failed'),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Pre-auth: plaintext handshake
|
|
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
|
final messageType = json['type'] as String?;
|
|
|
|
if (messageType == 'challenge') {
|
|
hostNonce = base64Decode(json['nonce'] as String);
|
|
final legacyHostClientId = json['hostClientId'] as String? ?? '';
|
|
final challengeContextHostIds = <String, String>{};
|
|
final challengeContexts = json['authContexts'] as List<dynamic>? ?? const [];
|
|
for (final item in challengeContexts) {
|
|
if (item is! Map) continue;
|
|
final id = item['id'] as String?;
|
|
final hostClientId = item['hostClientId'] as String?;
|
|
if (id != null && id.isNotEmpty && hostClientId != null && hostClientId.isNotEmpty) {
|
|
challengeContextHostIds[id] = hostClientId;
|
|
}
|
|
}
|
|
|
|
RemoteAuthContext? selectedContext;
|
|
if (authContextId != null && authContextId.isNotEmpty) {
|
|
for (final context in authContexts) {
|
|
if (context.id == authContextId) {
|
|
selectedContext = context;
|
|
break;
|
|
}
|
|
}
|
|
} else if (challengeContextHostIds.isNotEmpty) {
|
|
for (final context in authContexts) {
|
|
if (challengeContextHostIds.containsKey(context.id)) {
|
|
selectedContext = context;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
selectedContext = authContexts.first;
|
|
}
|
|
|
|
if (selectedContext == null ||
|
|
(challengeContextHostIds.isNotEmpty && !challengeContextHostIds.containsKey(selectedContext.id))) {
|
|
appLogger.w('CompanionRemote: No shared auth context with host');
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(
|
|
const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No shared identity'),
|
|
);
|
|
}
|
|
unawaited(_channel?.sink.close(4003, 'Authentication failed'));
|
|
return;
|
|
}
|
|
|
|
receivedHostClientId = challengeContextHostIds[selectedContext.id] ?? legacyHostClientId;
|
|
clientNonce = auth.generateNonce();
|
|
|
|
if (expectedHostClientId.isNotEmpty && receivedHostClientId != expectedHostClientId) {
|
|
appLogger.w('CompanionRemote: Host client ID mismatch');
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(
|
|
const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'Host identity mismatch'),
|
|
);
|
|
}
|
|
unawaited(_channel?.sink.close(4003, 'Authentication failed'));
|
|
return;
|
|
}
|
|
|
|
final authTag = auth.computeAuthTag(
|
|
homeSecret: selectedContext.homeSecret,
|
|
hostNonce: hostNonce!,
|
|
clientNonce: clientNonce!,
|
|
hostClientId: receivedHostClientId!,
|
|
userUUID: selectedContext.userUuid,
|
|
clientIdentifier: selectedContext.clientIdentifier,
|
|
deviceName: deviceName,
|
|
platform: platform,
|
|
);
|
|
|
|
_channel!.sink.add(
|
|
jsonEncode({
|
|
'type': 'auth',
|
|
'authContextId': selectedContext.id,
|
|
'clientNonce': base64Encode(clientNonce!),
|
|
'userUUID': selectedContext.userUuid,
|
|
'clientIdentifier': selectedContext.clientIdentifier,
|
|
'deviceName': deviceName,
|
|
'platform': platform,
|
|
'authTag': authTag,
|
|
}),
|
|
);
|
|
|
|
_sessionEncKey = await auth.deriveSessionEncKey(selectedContext.homeSecret, hostNonce!, clientNonce!);
|
|
_sendCounter = 0;
|
|
_recvCounter = 0;
|
|
_selectedAuthContextId = selectedContext.id;
|
|
_selectedHostClientId = receivedHostClientId;
|
|
} else if (messageType == 'authFailed') {
|
|
appLogger.w('CompanionRemote: Authentication failed');
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(
|
|
const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'Authentication failed'),
|
|
);
|
|
}
|
|
_errorController.add(
|
|
const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'Authentication failed'),
|
|
);
|
|
_connectionStateController.add(RemoteSessionStatus.error);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Failed to parse message', error: e);
|
|
}
|
|
},
|
|
onDone: () {
|
|
appLogger.d('CompanionRemote: Connection closed');
|
|
_deviceDisconnectedController.add(null);
|
|
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
|
_isAuthenticated = false;
|
|
_sessionEncKey = null;
|
|
_selectedAuthContextId = null;
|
|
_selectedHostClientId = null;
|
|
stopKeepalive();
|
|
},
|
|
onError: (error) {
|
|
appLogger.e('CompanionRemote: Connection error', error: error);
|
|
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(error);
|
|
}
|
|
|
|
_errorController.add(
|
|
RemotePeerError(
|
|
type: RemotePeerErrorType.connectionFailed,
|
|
message: 'Connection error: $error',
|
|
originalError: error,
|
|
),
|
|
);
|
|
_connectionStateController.add(RemoteSessionStatus.error);
|
|
},
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Failed to connect', error: e);
|
|
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(e);
|
|
}
|
|
|
|
_errorController.add(
|
|
RemotePeerError(type: RemotePeerErrorType.connectionFailed, message: 'Failed to connect: $e', originalError: e),
|
|
);
|
|
}
|
|
|
|
return completer.future.timeout(
|
|
const Duration(seconds: 15),
|
|
onTimeout: () async {
|
|
if (_channel != null) {
|
|
try {
|
|
await _channel!.sink.close();
|
|
} catch (e) {
|
|
appLogger.d('CompanionRemote: channel close on timeout failed', error: e);
|
|
}
|
|
_channel = null;
|
|
}
|
|
throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session');
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Race WebSocket connections to multiple host addresses in parallel.
|
|
Future<String> joinSessionRacing(
|
|
String deviceName,
|
|
String platform,
|
|
List<String> hostAddresses,
|
|
List<int> homeSecret,
|
|
String hostClientId,
|
|
String userUUID,
|
|
String clientIdentifier,
|
|
) async {
|
|
final auth = RemoteAuthService.instance;
|
|
final context = RemoteAuthContext(
|
|
id: auth.computeAuthContextId(homeSecret),
|
|
backend: 'legacy',
|
|
connectionId: '',
|
|
homeSecret: homeSecret,
|
|
discoveryKey: const [],
|
|
clientIdentifier: clientIdentifier,
|
|
userUuid: userUUID,
|
|
allowedUserUuids: [userUUID],
|
|
);
|
|
return joinSessionRacingWithContexts(
|
|
deviceName,
|
|
platform,
|
|
hostAddresses,
|
|
[context],
|
|
authContextId: context.id,
|
|
expectedHostClientId: hostClientId,
|
|
);
|
|
}
|
|
|
|
/// Race WebSocket connections and authenticate with the selected shared identity.
|
|
Future<String> joinSessionRacingWithContexts(
|
|
String deviceName,
|
|
String platform,
|
|
List<String> hostAddresses,
|
|
List<RemoteAuthContext> authContexts, {
|
|
String? authContextId,
|
|
String expectedHostClientId = '',
|
|
}) async {
|
|
if (authContexts.isEmpty) {
|
|
throw const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No auth contexts available');
|
|
}
|
|
|
|
if (hostAddresses.length == 1) {
|
|
await joinSessionWithContexts(
|
|
deviceName,
|
|
platform,
|
|
hostAddresses.first,
|
|
authContexts,
|
|
authContextId: authContextId,
|
|
expectedHostClientId: expectedHostClientId,
|
|
);
|
|
return hostAddresses.first;
|
|
}
|
|
|
|
appLogger.d('CompanionRemote: Racing connections to ${hostAddresses.length} addresses');
|
|
|
|
// Race: try to connect to all addresses, first one to get a challenge wins
|
|
final completer = Completer<String>();
|
|
final channels = <IOWebSocketChannel>[];
|
|
final subs = <StreamSubscription>[];
|
|
|
|
void cleanup() {
|
|
for (final sub in subs) {
|
|
sub.cancel();
|
|
}
|
|
for (final ch in channels) {
|
|
try {
|
|
ch.sink.close();
|
|
} catch (e) {
|
|
appLogger.d('CompanionRemote: race-loser close ignored', error: e);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (final address in hostAddresses) {
|
|
try {
|
|
final url = 'ws://$address/ws';
|
|
final channel = IOWebSocketChannel.connect(Uri.parse(url), connectTimeout: const Duration(seconds: 5));
|
|
channels.add(channel);
|
|
|
|
final sub = channel.stream.listen(
|
|
(data) {
|
|
try {
|
|
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
|
// First address to send us a challenge wins the race
|
|
if (json['type'] == 'challenge' && !completer.isCompleted) {
|
|
appLogger.d('CompanionRemote: Race winner: $address');
|
|
completer.complete(address);
|
|
}
|
|
} catch (e) {
|
|
appLogger.d('CompanionRemote: race message parse skipped', error: e);
|
|
}
|
|
},
|
|
onError: (_) {},
|
|
onDone: () {},
|
|
);
|
|
subs.add(sub);
|
|
} catch (e) {
|
|
appLogger.d('CompanionRemote: Race candidate $address failed to start: $e');
|
|
}
|
|
}
|
|
|
|
if (channels.isEmpty) {
|
|
throw const RemotePeerError(
|
|
type: RemotePeerErrorType.connectionFailed,
|
|
message: 'Failed to connect to any address',
|
|
);
|
|
}
|
|
|
|
try {
|
|
final winner = await completer.future.namedTimeout(
|
|
const Duration(seconds: 10),
|
|
operation: 'CompanionRemote race connect',
|
|
);
|
|
cleanup();
|
|
|
|
// Set up the proper managed connection on the winning address
|
|
await joinSessionWithContexts(
|
|
deviceName,
|
|
platform,
|
|
winner,
|
|
authContexts,
|
|
authContextId: authContextId,
|
|
expectedHostClientId: expectedHostClientId,
|
|
);
|
|
return winner;
|
|
} on TimeoutException {
|
|
cleanup();
|
|
throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out connecting to all addresses');
|
|
}
|
|
}
|
|
|
|
// ── Encrypted send/receive ──
|
|
|
|
// Serializes async sends to prevent counter interleaving
|
|
Future<void>? _sendChain;
|
|
|
|
Future<List<int>> _encryptOutgoing(String plaintext) async {
|
|
final encrypted = await RemoteAuthService.instance.encrypt(
|
|
_sessionEncKey!,
|
|
utf8.encode(plaintext),
|
|
isHost: _role == RemoteSessionRole.host,
|
|
counter: _sendCounter,
|
|
);
|
|
_sendCounter++;
|
|
return encrypted;
|
|
}
|
|
|
|
Future<void> _sendEncryptedToSocket(WebSocket socket, String plaintext) async {
|
|
if (_sessionEncKey == null) return;
|
|
final encrypted = await _encryptOutgoing(plaintext);
|
|
socket.add(encrypted);
|
|
}
|
|
|
|
Future<String?> _decryptIncoming(dynamic data) async {
|
|
if (_sessionEncKey == null) return null;
|
|
try {
|
|
final auth = RemoteAuthService.instance;
|
|
final bytes = data is List<int> ? data : utf8.encode(data as String);
|
|
final decrypted = await auth.decrypt(
|
|
bytes,
|
|
_sessionEncKey!,
|
|
fromHost: _role == RemoteSessionRole.remote, // If we're remote, incoming is from host
|
|
expectedCounter: _recvCounter,
|
|
);
|
|
_recvCounter++;
|
|
return utf8.decode(decrypted);
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Decryption failed (counter=$_recvCounter)', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Commands ──
|
|
|
|
@override
|
|
void sendPing() {
|
|
if (isConnected) {
|
|
sendCommand(const RemoteCommand(type: RemoteCommandType.ping));
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onPongTimeout() {
|
|
// Not used — pong timeout is disabled for companion remote.
|
|
}
|
|
|
|
bool _shouldSendAck(RemoteCommand command) {
|
|
return command.type != RemoteCommandType.ping &&
|
|
command.type != RemoteCommandType.pong &&
|
|
command.type != RemoteCommandType.ack &&
|
|
command.type != RemoteCommandType.deviceInfo;
|
|
}
|
|
|
|
void _sendAck(RemoteCommand _) {
|
|
sendCommand(const RemoteCommand(type: RemoteCommandType.ack));
|
|
}
|
|
|
|
void _sendPong() {
|
|
sendCommand(const RemoteCommand(type: RemoteCommandType.pong));
|
|
}
|
|
|
|
void sendDeviceInfo(String deviceName, String platform) {
|
|
sendCommand(
|
|
RemoteCommand(
|
|
type: RemoteCommandType.deviceInfo,
|
|
data: {'id': _myPeerId, 'name': deviceName, 'platform': platform, 'role': _role?.name},
|
|
),
|
|
);
|
|
}
|
|
|
|
void sendCommand(RemoteCommand command) {
|
|
if (_sessionEncKey == null || !_isAuthenticated) {
|
|
appLogger.w('CompanionRemote: No connection to send command');
|
|
return;
|
|
}
|
|
|
|
// Chain sends to prevent counter interleaving from concurrent async encrypts
|
|
_sendChain = (_sendChain ?? Future.value()).then((_) async {
|
|
try {
|
|
final json = jsonEncode(command.toJson());
|
|
final encrypted = await _encryptOutgoing(json);
|
|
|
|
if (_role == RemoteSessionRole.host && _clientSocket != null) {
|
|
_clientSocket!.add(encrypted);
|
|
} else if (_role == RemoteSessionRole.remote && _channel != null) {
|
|
_channel!.sink.add(encrypted);
|
|
}
|
|
appLogger.d('CompanionRemote: Sent command: ${command.type}');
|
|
} catch (e) {
|
|
appLogger.e('CompanionRemote: Failed to send command', error: e);
|
|
_errorController.add(
|
|
RemotePeerError(
|
|
type: RemotePeerErrorType.dataChannelError,
|
|
message: 'Failed to send command: $e',
|
|
originalError: e,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> disconnect() async {
|
|
appLogger.d('CompanionRemote: Disconnecting');
|
|
|
|
stopKeepalive();
|
|
|
|
if (_clientSocket != null) {
|
|
try {
|
|
await _clientSocket!.close();
|
|
} catch (e) {
|
|
appLogger.d('CompanionRemote: client socket close ignored', error: e);
|
|
}
|
|
_clientSocket = null;
|
|
}
|
|
|
|
if (_channel != null) {
|
|
try {
|
|
await _channel!.sink.close();
|
|
} catch (e) {
|
|
appLogger.d('CompanionRemote: channel close ignored', error: e);
|
|
}
|
|
_channel = null;
|
|
}
|
|
|
|
if (_server != null) {
|
|
await _server!.close();
|
|
_server = null;
|
|
}
|
|
|
|
_myPeerId = null;
|
|
_hostAddress = null;
|
|
_role = null;
|
|
_selectedAuthContextId = null;
|
|
_selectedHostClientId = null;
|
|
_sessionEncKey = null;
|
|
_sendCounter = 0;
|
|
_recvCounter = 0;
|
|
_isAuthenticated = false;
|
|
_sendChain = null;
|
|
_failedAuthAttempts.clear();
|
|
_authLockouts.clear();
|
|
|
|
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
|
}
|
|
|
|
/// Whether the HTTP server is currently running.
|
|
bool get isServerRunning => _server != null;
|
|
|
|
Future<void> dispose() async {
|
|
await disconnect();
|
|
await _commandReceivedController.close();
|
|
await _deviceConnectedController.close();
|
|
await _deviceDisconnectedController.close();
|
|
await _errorController.close();
|
|
await _connectionStateController.close();
|
|
}
|
|
}
|